From c03c18e93a9cca155a988148c5387becf964cd69 Mon Sep 17 00:00:00 2001
From: Yohann D'ANELLO <yohann.danello@gmail.com>
Date: Fri, 4 Sep 2020 15:53:00 +0200
Subject: [PATCH] Test and cover treasury app

---
 .../templates/activity/activity_entry.html    |   2 +-
 apps/treasury/admin.py                        |   4 +-
 apps/treasury/api/views.py                    |  10 +-
 apps/treasury/forms.py                        |  10 +-
 apps/treasury/models.py                       |   4 +-
 .../treasury}/static/img/Finalist.png         | Bin
 .../treasury}/static/img/Kataclist.png        | Bin
 .../treasury}/static/img/Listorique.png       | Bin
 .../treasury}/static/img/Monopolist.png       | Bin
 .../treasury}/static/img/Saperlistpopette.png | Bin
 .../treasury}/static/img/Satellist.png        | Bin
 apps/treasury/tables.py                       |   2 +-
 .../templates/treasury/invoice_list.html      |   2 +-
 .../templates/treasury/invoice_sample.tex     |   2 +-
 .../templates/treasury/remittance_list.html   |   2 +-
 .../templates/treasury/sogecredit_list.html   |   5 +-
 apps/treasury/tests/__init__.py               |   0
 apps/treasury/tests/test_treasury.py          | 403 ++++++++++++++++++
 apps/treasury/views.py                        |  17 +-
 note_kfet/static/js/transfer.js               |   6 +-
 20 files changed, 438 insertions(+), 31 deletions(-)
 rename {note_kfet => apps/treasury}/static/img/Finalist.png (100%)
 rename {note_kfet => apps/treasury}/static/img/Kataclist.png (100%)
 rename {note_kfet => apps/treasury}/static/img/Listorique.png (100%)
 rename {note_kfet => apps/treasury}/static/img/Monopolist.png (100%)
 rename {note_kfet => apps/treasury}/static/img/Saperlistpopette.png (100%)
 rename {note_kfet => apps/treasury}/static/img/Satellist.png (100%)
 create mode 100644 apps/treasury/tests/__init__.py
 create mode 100644 apps/treasury/tests/test_treasury.py

diff --git a/apps/activity/templates/activity/activity_entry.html b/apps/activity/templates/activity/activity_entry.html
index d59a4c48..d778490f 100644
--- a/apps/activity/templates/activity/activity_entry.html
+++ b/apps/activity/templates/activity/activity_entry.html
@@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
 <h1 class="text-white">{{ title }}</h1>
 <div class="row">
     <div class="col-xl-12">
-        <div class="btn-group btn-group-toggle bg-light" style="width: 100%" data-toggle="buttons">
+        <div class="btn-group btn-group-toggle bg-light" style="width: 100%">
             <a href="{% url "note:transfer" %}#transfer" class="btn btn-sm btn-outline-primary">
                 {% trans "Transfer" %}
             </a>
diff --git a/apps/treasury/admin.py b/apps/treasury/admin.py
index 1db820b2..25b4f4cb 100644
--- a/apps/treasury/admin.py
+++ b/apps/treasury/admin.py
@@ -24,9 +24,7 @@ class RemittanceAdmin(admin.ModelAdmin):
     list_display = ('remittance_type', 'date', 'comment', 'count', 'amount', 'closed', )
 
     def has_change_permission(self, request, obj=None):
-        if not obj:
-            return True
-        return not obj.closed and super().has_change_permission(request, obj)
+        return not obj or (not obj.closed and super().has_change_permission(request, obj))
 
 
 @admin.register(SogeCredit, site=admin_site)
diff --git a/apps/treasury/api/views.py b/apps/treasury/api/views.py
index ee97e6ac..82a0ed1e 100644
--- a/apps/treasury/api/views.py
+++ b/apps/treasury/api/views.py
@@ -16,7 +16,7 @@ class InvoiceViewSet(ReadProtectedModelViewSet):
     The djangorestframework plugin will get all `Invoice` objects, serialize it to JSON with the given serializer,
     then render it on /api/treasury/invoice/
     """
-    queryset = Invoice.objects.all()
+    queryset = Invoice.objects.order_by("id").all()
     serializer_class = InvoiceSerializer
     filter_backends = [DjangoFilterBackend]
     filterset_fields = ['bde', ]
@@ -28,7 +28,7 @@ class ProductViewSet(ReadProtectedModelViewSet):
     The djangorestframework plugin will get all `Product` objects, serialize it to JSON with the given serializer,
     then render it on /api/treasury/product/
     """
-    queryset = Product.objects.all()
+    queryset = Product.objects.order_by("invoice_id", "id").all()
     serializer_class = ProductSerializer
     filter_backends = [SearchFilter]
     search_fields = ['$designation', ]
@@ -40,7 +40,7 @@ class RemittanceTypeViewSet(ReadProtectedModelViewSet):
     The djangorestframework plugin will get all `RemittanceType` objects, serialize it to JSON with the given serializer
     then render it on /api/treasury/remittance_type/
     """
-    queryset = RemittanceType.objects
+    queryset = RemittanceType.objects.order_by("id")
     serializer_class = RemittanceTypeSerializer
 
 
@@ -50,7 +50,7 @@ class RemittanceViewSet(ReadProtectedModelViewSet):
     The djangorestframework plugin will get all `Remittance` objects, serialize it to JSON with the given serializer,
     then render it on /api/treasury/remittance/
     """
-    queryset = Remittance.objects
+    queryset = Remittance.objects.order_by("id")
     serializer_class = RemittanceSerializer
 
 
@@ -60,5 +60,5 @@ class SogeCreditViewSet(ReadProtectedModelViewSet):
     The djangorestframework plugin will get all `SogeCredit` objects, serialize it to JSON with the given serializer,
     then render it on /api/treasury/soge_credit/
     """
-    queryset = SogeCredit.objects
+    queryset = SogeCredit.objects.order_by("id")
     serializer_class = SogeCreditSerializer
diff --git a/apps/treasury/forms.py b/apps/treasury/forms.py
index 38da324d..c2461f76 100644
--- a/apps/treasury/forms.py
+++ b/apps/treasury/forms.py
@@ -16,21 +16,15 @@ class InvoiceForm(forms.ModelForm):
     """
 
     def clean(self):
+        # If the invoice is locked, it can't be updated.
         if self.instance and self.instance.locked:
             for field_name in self.fields:
                 self.cleaned_data[field_name] = getattr(self.instance, field_name)
             self.errors.clear()
+            self.add_error(None, _('This invoice is locked and can no longer be edited.'))
             return self.cleaned_data
         return super().clean()
 
-    def save(self, commit=True):
-        """
-        If the invoice is locked, don't save it
-        """
-        if not self.instance.locked:
-            super().save(commit)
-        return self.instance
-
     class Meta:
         model = Invoice
         exclude = ('bde', 'date', 'tex', )
diff --git a/apps/treasury/models.py b/apps/treasury/models.py
index 6d5b4021..762c7bb5 100644
--- a/apps/treasury/models.py
+++ b/apps/treasury/models.py
@@ -85,7 +85,7 @@ class Invoice(models.Model):
 
         old_invoice = Invoice.objects.filter(id=self.id)
         if old_invoice.exists():
-            if old_invoice.get().locked:
+            if old_invoice.get().locked and not self._force_save:
                 raise ValidationError(_("This invoice is locked and can no longer be edited."))
 
         products = self.products.all()
@@ -224,7 +224,7 @@ class Remittance(models.Model):
 
     def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
         # Check if all transactions have the right type.
-        if self.transactions.filter(~Q(source=self.remittance_type.note)).exists():
+        if self.transactions.exists() and self.transactions.filter(~Q(source=self.remittance_type.note)).exists():
             raise ValidationError("All transactions in a remittance must have the same type")
 
         return super().save(force_insert, force_update, using, update_fields)
diff --git a/note_kfet/static/img/Finalist.png b/apps/treasury/static/img/Finalist.png
similarity index 100%
rename from note_kfet/static/img/Finalist.png
rename to apps/treasury/static/img/Finalist.png
diff --git a/note_kfet/static/img/Kataclist.png b/apps/treasury/static/img/Kataclist.png
similarity index 100%
rename from note_kfet/static/img/Kataclist.png
rename to apps/treasury/static/img/Kataclist.png
diff --git a/note_kfet/static/img/Listorique.png b/apps/treasury/static/img/Listorique.png
similarity index 100%
rename from note_kfet/static/img/Listorique.png
rename to apps/treasury/static/img/Listorique.png
diff --git a/note_kfet/static/img/Monopolist.png b/apps/treasury/static/img/Monopolist.png
similarity index 100%
rename from note_kfet/static/img/Monopolist.png
rename to apps/treasury/static/img/Monopolist.png
diff --git a/note_kfet/static/img/Saperlistpopette.png b/apps/treasury/static/img/Saperlistpopette.png
similarity index 100%
rename from note_kfet/static/img/Saperlistpopette.png
rename to apps/treasury/static/img/Saperlistpopette.png
diff --git a/note_kfet/static/img/Satellist.png b/apps/treasury/static/img/Satellist.png
similarity index 100%
rename from note_kfet/static/img/Satellist.png
rename to apps/treasury/static/img/Satellist.png
diff --git a/apps/treasury/tables.py b/apps/treasury/tables.py
index 14044f1c..9a72ecf3 100644
--- a/apps/treasury/tables.py
+++ b/apps/treasury/tables.py
@@ -34,7 +34,7 @@ class InvoiceTable(tables.Table):
 
     delete = tables.LinkColumn(
         'treasury:invoice_delete',
-        args=[A('pk')],
+        args=[A('id')],
         verbose_name=_("delete"),
         text=_("Delete"),
         attrs={
diff --git a/apps/treasury/templates/treasury/invoice_list.html b/apps/treasury/templates/treasury/invoice_list.html
index 32c1b1c1..d9cd8a3e 100644
--- a/apps/treasury/templates/treasury/invoice_list.html
+++ b/apps/treasury/templates/treasury/invoice_list.html
@@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
 {% block content %}
 <div class="row">
     <div class="col-xl-12">
-        <div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0" data-toggle="buttons">
+        <div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
             <a href="#" class="btn btn-sm btn-outline-primary active">
                 {% trans "Invoice" %}s
             </a>
diff --git a/apps/treasury/templates/treasury/invoice_sample.tex b/apps/treasury/templates/treasury/invoice_sample.tex
index 4e6342b0..d7ec7391 100644
--- a/apps/treasury/templates/treasury/invoice_sample.tex
+++ b/apps/treasury/templates/treasury/invoice_sample.tex
@@ -58,7 +58,7 @@
         \parbox[b][\paperheight]{\paperwidth}{%
             \vfill
             \centering
-            {\transparent{0.1}\includegraphics[width=\textwidth]{../../static/img/{{ obj.bde }}}}%
+            {\transparent{0.1}\includegraphics[width=\textwidth]{../../apps/treasury/static/img/{{ obj.bde }}}}%
             \vfill
         }
     }
diff --git a/apps/treasury/templates/treasury/remittance_list.html b/apps/treasury/templates/treasury/remittance_list.html
index c400f18f..8ced1ad0 100644
--- a/apps/treasury/templates/treasury/remittance_list.html
+++ b/apps/treasury/templates/treasury/remittance_list.html
@@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
 {% block content %}
 <div class="row">
     <div class="col-xl-12">
-        <div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0" data-toggle="buttons">
+        <div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
             <a href="{% url "treasury:invoice_list" %}" class="btn btn-sm btn-outline-primary">
                 {% trans "Invoice" %}s
             </a>
diff --git a/apps/treasury/templates/treasury/sogecredit_list.html b/apps/treasury/templates/treasury/sogecredit_list.html
index c3862811..1eb1aba5 100644
--- a/apps/treasury/templates/treasury/sogecredit_list.html
+++ b/apps/treasury/templates/treasury/sogecredit_list.html
@@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
 {% block content %}
 <div class="row">
     <div class="col-xl-12">
-        <div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0" data-toggle="buttons">
+        <div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
             <a href="{% url "treasury:invoice_list" %}" class="btn btn-sm btn-outline-primary">
                 {% trans "Invoice" %}s
             </a>
@@ -59,9 +59,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
         function reloadTable() {
             let pattern = searchbar_obj.val();
 
-            if (pattern === old_pattern || pattern === "")
-                return;
-
             $("#credits_table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + (
                 invalid_only_obj.is(':checked') ? "&valid=false" : "") + " #credits_table");
 
diff --git a/apps/treasury/tests/__init__.py b/apps/treasury/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/apps/treasury/tests/test_treasury.py b/apps/treasury/tests/test_treasury.py
new file mode 100644
index 00000000..15d35cb3
--- /dev/null
+++ b/apps/treasury/tests/test_treasury.py
@@ -0,0 +1,403 @@
+# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from django.contrib.auth.models import User
+from django.core.exceptions import ValidationError
+from django.db.models import Q
+from django.test import TestCase
+from django.urls import reverse
+
+from member.models import Membership, Club
+from note.models import SpecialTransaction, NoteSpecial, Transaction
+from treasury.models import Invoice, Product, Remittance, RemittanceType, SogeCredit
+
+
+class TestInvoices(TestCase):
+    """
+    Check that invoices can be created and rendered properly.
+    """
+    def setUp(self) -> None:
+        self.user = User.objects.create_superuser(
+            username="admintoto",
+            password="totototo",
+            email="admin@example.com",
+        )
+        self.client.force_login(self.user)
+        sess = self.client.session
+        sess["permission_mask"] = 42
+        sess.save()
+
+        self.invoice = Invoice.objects.create(
+            id=1,
+            object="Object",
+            description="Description",
+            name="Me",
+            address="Earth",
+            acquitted=False,
+        )
+        self.product = Product.objects.create(
+            invoice=self.invoice,
+            designation="Product",
+            quantity=3,
+            amount=3.14,
+        )
+
+    def test_admin_page(self):
+        """
+        Display the invoice admin page.
+        """
+        response = self.client.get(reverse("admin:index") + "treasury/invoice/")
+        self.assertEqual(response.status_code, 200)
+
+    def test_invoices_list(self):
+        """
+        Display the list of invoices.
+        """
+        response = self.client.get(reverse("treasury:invoice_list"))
+        self.assertEqual(response.status_code, 200)
+
+    def test_invoice_create(self):
+        """
+        Try to create a new invoice.
+        """
+        response = self.client.get(reverse("treasury:invoice_create"))
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(reverse("treasury:invoice_create"), data={
+            "id": 42,
+            "object": "Same object",
+            "description": "Longer description",
+            "name": "Me and others",
+            "address": "Alwways earth",
+            "acquitted": True,
+            "products-0-designation": "Designation",
+            "products-0-quantity": 1,
+            "products-0-amount": 42,
+            "products-TOTAL_FORMS": 1,
+            "products-INITIAL_FORMS": 0,
+            "products-MIN_NUM_FORMS": 0,
+            "products-MAX_NUM_FORMS": 1000,
+        })
+        self.assertRedirects(response, reverse("treasury:invoice_list"), 302, 200)
+        self.assertTrue(Invoice.objects.filter(object="Same object", id=42).exists())
+        self.assertTrue(Product.objects.filter(designation="Designation", invoice_id=42).exists())
+        self.assertTrue(Invoice.objects.get(id=42).tex)
+
+    def test_invoice_update(self):
+        """
+        Try to update an invoice.
+        """
+        response = self.client.get(reverse("treasury:invoice_update", args=(self.invoice.id,)))
+        self.assertEqual(response.status_code, 200)
+
+        data = {
+            "object": "Same object",
+            "description": "Longer description",
+            "name": "Me and others",
+            "address": "Always earth",
+            "acquitted": True,
+            "locked": True,
+            "products-0-designation": "Designation",
+            "products-0-quantity": 1,
+            "products-0-amount": 4200,
+            "products-1-designation": "Second designation",
+            "products-1-quantity": 5,
+            "products-1-amount": -1800,
+            "products-TOTAL_FORMS": 2,
+            "products-INITIAL_FORMS": 0,
+            "products-MIN_NUM_FORMS": 0,
+            "products-MAX_NUM_FORMS": 1000,
+        }
+
+        response = self.client.post(reverse("treasury:invoice_update", args=(self.invoice.id,)), data=data)
+        self.assertRedirects(response, reverse("treasury:invoice_list"), 302, 200)
+        self.invoice.refresh_from_db()
+        self.assertTrue(Invoice.objects.filter(pk=1, object="Same object", locked=True).exists())
+        self.assertTrue(Product.objects.filter(designation="Second designation", invoice_id=1).exists())
+
+        # Resend the same data, but the invoice is locked.
+        response = self.client.get(reverse("treasury:invoice_update", args=(self.invoice.id,)))
+        self.assertTrue(response.status_code, 200)
+        response = self.client.post(reverse("treasury:invoice_update", args=(self.invoice.id,)), data=data)
+        self.assertTrue(response.status_code, 200)
+
+    def test_delete_invoice(self):
+        """
+        Try to delete an invoice.
+        """
+        response = self.client.get(reverse("treasury:invoice_delete", args=(self.invoice.id,)))
+        self.assertEqual(response.status_code, 200)
+
+        # Can't delete a locked invoice
+        self.invoice.locked = True
+        self.invoice.save()
+        response = self.client.delete(reverse("treasury:invoice_delete", args=(self.invoice.id,)))
+        self.assertEqual(response.status_code, 403)
+        self.assertTrue(Invoice.objects.filter(pk=self.invoice.id).exists())
+
+        # Unlock invoice and truly delete it.
+        self.invoice.locked = False
+        self.invoice._force_save = True
+        self.invoice.save()
+        response = self.client.delete(reverse("treasury:invoice_delete", args=(self.invoice.id,)))
+        self.assertRedirects(response, reverse("treasury:invoice_list"), 302, 200)
+        self.assertFalse(Invoice.objects.filter(pk=self.invoice.id).exists())
+
+    def test_invoice_render_pdf(self):
+        """
+        Generate the PDF file of an invoice.
+        """
+        response = self.client.get(reverse("treasury:invoice_render", args=(self.invoice.id,)))
+        self.assertEqual(response.status_code, 200)
+
+    def test_invoice_api(self):
+        """
+        Load some API pages
+        """
+        response = self.client.get("/api/treasury/invoice/")
+        self.assertEqual(response.status_code, 200)
+        response = self.client.get("/api/treasury/product/")
+        self.assertEqual(response.status_code, 200)
+
+
+class TestRemittances(TestCase):
+    """
+    Create some credits and close remittances.
+    """
+
+    fixtures = ('initial',)
+
+    def setUp(self) -> None:
+        self.user = User.objects.create_superuser(
+            username="admintoto",
+            password="totototo",
+            email="admin@example.com",
+        )
+        self.client.force_login(self.user)
+        sess = self.client.session
+        sess["permission_mask"] = 42
+        sess.save()
+
+        self.credit = SpecialTransaction.objects.create(
+            source=NoteSpecial.objects.get(special_type="Chèque"),
+            destination=self.user.note,
+            amount=4200,
+            reason="Credit",
+            last_name="TOTO",
+            first_name="Toto",
+            bank="Société générale",
+        )
+
+        self.second_credit = SpecialTransaction.objects.create(
+            source=self.user.note,
+            destination=NoteSpecial.objects.get(special_type="Chèque"),
+            amount=424200,
+            reason="Second credit",
+            last_name="TOTO",
+            first_name="Toto",
+            bank="Société générale",
+        )
+
+        self.remittance = Remittance.objects.create(
+            remittance_type=RemittanceType.objects.get(),
+            comment="Test remittance",
+            closed=False,
+        )
+        self.credit.specialtransactionproxy.remittance = self.remittance
+        self.credit.specialtransactionproxy.save()
+
+    def test_admin_page(self):
+        """
+        Load the admin page.
+        """
+        response = self.client.get(reverse("admin:index") + "treasury/remittance/")
+        self.assertEqual(response.status_code, 200)
+
+    def test_remittances_list(self):
+        """
+        Display the remittance list.
+        :return:
+        """
+        response = self.client.get(reverse("treasury:remittance_list"))
+        self.assertEqual(response.status_code, 200)
+
+    def test_remittance_create(self):
+        """
+        Create a new Remittance.
+        """
+        response = self.client.get(reverse("treasury:remittance_create"))
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(reverse("treasury:remittance_create"), data=dict(
+            remittance_type=RemittanceType.objects.get().pk,
+            comment="Created remittance",
+        ))
+        self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200)
+        self.assertTrue(Remittance.objects.filter(comment="Created remittance").exists())
+
+    def test_remittance_update(self):
+        """
+        Update an existing remittance.
+        """
+        response = self.client.get(reverse("treasury:remittance_update", args=(self.remittance.pk,)))
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(reverse("treasury:remittance_update", args=(self.remittance.pk,)), data=dict(
+            comment="Updated remittance",
+        ))
+        self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200)
+        self.assertTrue(Remittance.objects.filter(comment="Updated remittance").exists())
+
+    def test_remittance_close(self):
+        """
+        Try to close an open remittance.
+        """
+        response = self.client.get(reverse("treasury:remittance_update", args=(self.remittance.pk,)))
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(reverse("treasury:remittance_update", args=(self.remittance.pk,)), data=dict(
+            comment="Closed remittance",
+            close=True,
+        ))
+        self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200)
+        self.assertTrue(Remittance.objects.filter(comment="Closed remittance", closed=True).exists())
+
+    def test_remittance_link_transaction(self):
+        """
+        Link a transaction to an open remittance.
+        """
+        response = self.client.get(reverse("treasury:link_transaction", args=(self.credit.pk,)))
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(reverse("treasury:link_transaction", args=(self.credit.pk,)), data=dict(
+            remittance=self.remittance.pk,
+            last_name="Last Name",
+            first_name="First Name",
+            bank="Bank",
+        ))
+        self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200)
+        self.credit.refresh_from_db()
+        self.assertEqual(self.credit.last_name, "Last Name")
+        self.assertEqual(self.remittance.transactions.count(), 1)
+
+        response = self.client.get(reverse("treasury:unlink_transaction", args=(self.credit.pk,)))
+        self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200)
+
+    def test_invoice_api(self):
+        """
+        Load some API pages
+        """
+        response = self.client.get("/api/treasury/remittance_type/")
+        self.assertEqual(response.status_code, 200)
+        response = self.client.get("/api/treasury/remittance/")
+        self.assertEqual(response.status_code, 200)
+
+
+class TestSogeCredits(TestCase):
+    """
+    Check that credits from the Société générale are working correctly.
+    """
+
+    fixtures = ('initial',)
+
+    def setUp(self) -> None:
+        self.user = User.objects.create_superuser(
+            username="admintoto",
+            password="totototo",
+            email="admin@example.com",
+        )
+        self.client.force_login(self.user)
+        sess = self.client.session
+        sess["permission_mask"] = 42
+        sess.save()
+
+        self.kfet = Club.objects.get(name="Kfet")
+        self.bde = self.kfet.parent_club
+
+        self.kfet_membership = Membership(
+            user=self.user,
+            club=self.kfet,
+        )
+        self.kfet_membership._force_renew_parent = True
+        self.kfet_membership._soge = True
+        self.kfet_membership.save()
+
+    def test_admin_page(self):
+        """
+        Render the admin page.
+        """
+        response = self.client.get(reverse("admin:index") + "treasury/sogecredit/")
+        self.assertEqual(response.status_code, 200)
+
+    def test_sogecredit_list(self):
+        """
+        Display the list of all credits.
+        """
+        response = self.client.get(reverse("treasury:soge_credits"))
+        self.assertEqual(response.status_code, 200)
+        response = self.client.get(reverse("treasury:soge_credits") + "?search=toto&valid=")
+        self.assertEqual(response.status_code, 200)
+
+    def test_validate_soge_credit(self):
+        """
+        Try to validate a credit.
+        """
+        soge_credit = SogeCredit.objects.get(user=self.user)
+
+        response = self.client.get(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)))
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)), data=dict(
+            validate=True,
+        ))
+        self.assertRedirects(response, reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)), 302, 200)
+        soge_credit.refresh_from_db()
+        self.assertTrue(soge_credit.valid)
+        self.user.note.refresh_from_db()
+        self.assertEqual(self.user.note.balance, 0)
+        self.assertEqual(
+            Transaction.objects.filter(Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3)
+        self.assertTrue(self.user.profile.soge)
+
+    def test_delete_soge_credit(self):
+        """
+        Try to invalidate a credit.
+        """
+        soge_credit = SogeCredit.objects.get(user=self.user)
+
+        response = self.client.get(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)))
+        self.assertEqual(response.status_code, 200)
+
+        try:
+            self.client.post(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)), data=dict(delete=True))
+            raise AssertionError("It is not possible to delete the soge credit until the note is not credited.")
+        except ValidationError:
+            pass
+
+        SpecialTransaction.objects.create(
+            source=NoteSpecial.objects.get(special_type="Carte bancaire"),
+            destination=self.user.note,
+            amount=self.bde.membership_fee_paid + self.kfet.membership_fee_paid,
+            quantity=1,
+            reason="Registration is not complete, pliz pay",
+            last_name="TOTO",
+            first_name="Toto",
+        )
+
+        response = self.client.post(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)),
+                                    data=dict(delete=True))
+        # 403 because no SogeCredit exists anymore, then a PermissionDenied is raised
+        self.assertRedirects(response, reverse("treasury:soge_credits"), 302, 403)
+        self.assertFalse(SogeCredit.objects.filter(pk=soge_credit.pk))
+        self.user.note.refresh_from_db()
+        self.assertEqual(self.user.note.balance, 0)
+        self.assertEqual(
+            Transaction.objects.filter(Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3)
+        self.assertFalse(self.user.profile.soge)
+
+    def test_invoice_api(self):
+        """
+        Load some API pages
+        """
+        response = self.client.get("/api/treasury/soge_credit/")
+        self.assertEqual(response.status_code, 200)
diff --git a/apps/treasury/views.py b/apps/treasury/views.py
index c2265289..5889f8b5 100644
--- a/apps/treasury/views.py
+++ b/apps/treasury/views.py
@@ -60,6 +60,11 @@ class InvoiceCreateView(ProtectQuerysetMixin, ProtectedCreateView):
 
         return context
 
+    def get_form(self, form_class=None):
+        form = super().get_form(form_class)
+        del form.fields["locked"]
+        return form
+
     def form_valid(self, form):
         ret = super().form_valid(form)
 
@@ -134,6 +139,11 @@ class InvoiceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
 
         return context
 
+    def get_form(self, form_class=None):
+        form = super().get_form(form_class)
+        del form.fields["id"]
+        return form
+
     def form_valid(self, form):
         ret = super().form_valid(form)
 
@@ -165,6 +175,11 @@ class InvoiceDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
     model = Invoice
     extra_context = {"title": _("Delete invoice")}
 
+    def delete(self, request, *args, **kwargs):
+        if self.get_object().locked:
+            raise PermissionDenied(_("This invoice is locked and can't be deleted."))
+        return super().delete(request, *args, **kwargs)
+
     def get_success_url(self):
         return reverse_lazy('treasury:invoice_list')
 
@@ -387,7 +402,7 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi
         if not request.user.is_authenticated:
             return self.handle_no_permission()
 
-        if not self.get_queryset().exists():
+        if not super().get_queryset().exists():
             raise PermissionDenied(_("You are not able to see the treasury interface."))
         return super().dispatch(request, *args, **kwargs)
 
diff --git a/note_kfet/static/js/transfer.js b/note_kfet/static/js/transfer.js
index cbae7456..e22d2b3f 100644
--- a/note_kfet/static/js/transfer.js
+++ b/note_kfet/static/js/transfer.js
@@ -96,7 +96,7 @@ $(document).ready(function() {
     let source = $("#source_note");
     let dest = $("#dest_note");
 
-    $("#type_transfer").click(function() {
+    $("#type_transfer").change(function() {
         if (LOCK)
             return;
 
@@ -117,7 +117,7 @@ $(document).ready(function() {
         location.hash = "transfer";
     });
 
-    $("#type_credit").click(function() {
+    $("#type_credit").change(function() {
         if (LOCK)
             return;
 
@@ -146,7 +146,7 @@ $(document).ready(function() {
         location.hash = "credit";
     });
 
-    $("#type_debit").click(function() {
+    $("#type_debit").change(function() {
         if (LOCK)
             return;
 
-- 
GitLab