Repository: svthalia/concrexit

Issue:
Change mandate no. increment to guarantee uniqueness
Consider https://github.com/svthalia/concrexit/blob/56ef628318d5bc4c4bbdc28a6eeac2d6c73cc218/website/payments/views.py#L44

In case an admin might manually remove a mandate (rather than invalidate it), manually change a user's mandate id, the newly generated mandate id might not always be unique. For example, if a user has mandate no. 1234-1 and 1234-3, the next number that will be generated is 1234-3 as well (because we take the `count() + 1`).

Solution: take the max mandate no. if it is of the form `<id>-<i>`, the new mandate no. will be `<id>-<i+1>`, else use the fallback we have already implemented. 

Other solution: do an additional check that the newly generated reference number is in fact really unique.


Discussion:
This issue occurred while discussing some problem with the bank with @ki-mberley

(Accidentally, the first batch was submitted to the bank as 'one time debits' while the mandate was actually a recurring. This caused some banks to disallow future debits with the same mandate reference, because they think that mandate no. was not valid anymore for a second transaction. The quick solution was to let Kimberley manually increment the mandate no. for the affected users, so the bank would consider them as 'new' again, and they could be correctly submitted as RCUR debits again)
Nice catch, good that we did not open it up for everyone. That would have been such a mess.

Resolving patch:
diff --git a/website/payments/models.py b/website/payments/models.py
--- a/website/payments/models.py
+++ b/website/payments/models.py
@@ -328,6 +328,7 @@ class BankAccount(models.Model):
 
     signature = models.TextField(verbose_name=_("signature"), blank=True, null=True,)
 
+    MANDATE_NO_DEFAULT_REGEX = r"^\d+-\d+$"
     mandate_no = models.CharField(
         verbose_name=_("mandate number"),
         max_length=255,
diff --git a/website/payments/services.py b/website/payments/services.py
--- a/website/payments/services.py
+++ b/website/payments/services.py
@@ -125,6 +125,18 @@ def process_batch(batch):
     send_tpay_batch_processing_emails(batch)
 
 
+def derive_next_mandate_no(member) -> str:
+    accounts = (
+        BankAccount.objects.filter(owner=PaymentUser.objects.get(pk=member.pk))
+        .exclude(mandate_no=None)
+        .filter(mandate_no__regex=BankAccount.MANDATE_NO_DEFAULT_REGEX)
+    )
+    new_mandate_no = 1 + max(
+        [int(account.mandate_no.split("-")[1]) for account in accounts], default=0
+    )
+    return f"{member.pk}-{new_mandate_no}"
+
+
 def send_tpay_batch_processing_emails(batch):
     """Send withdrawal notice emails to all members in a batch."""
     member_payments = batch.payments_set.values("paid_by").annotate(total=Sum("amount"))
diff --git a/website/payments/views.py b/website/payments/views.py
--- a/website/payments/views.py
+++ b/website/payments/views.py
@@ -35,20 +35,9 @@ class BankAccountCreateView(SuccessMessageMixin, CreateView):
     success_url = reverse_lazy("payments:bankaccount-list")
     success_message = _("Bank account saved successfully.")
 
-    def _derive_mandate_no(self) -> str:
-        count = (
-            BankAccount.objects.filter(
-                owner=PaymentUser.objects.get(pk=self.request.member.pk)
-            )
-            .exclude(mandate_no=None)
-            .count()
-            + 1
-        )
-        return f"{self.request.member.pk}-{count}"
-
     def get_context_data(self, **kwargs) -> dict:
         context = super().get_context_data(**kwargs)
-        context["mandate_no"] = self._derive_mandate_no()
+        context["mandate_no"] = services.derive_next_mandate_no(self.request.member)
         context["creditor_id"] = settings.SEPA_CREDITOR_ID
         return context
 
@@ -57,7 +46,9 @@ def post(self, request, *args, **kwargs) -> HttpResponse:
         request.POST["owner"] = self.request.member.pk
         if "direct_debit" in request.POST:
             request.POST["valid_from"] = timezone.now()
-            request.POST["mandate_no"] = self._derive_mandate_no()
+            request.POST["mandate_no"] = services.derive_next_mandate_no(
+                self.request.member
+            )
         else:
             request.POST["valid_from"] = None
             request.POST["mandate_no"] = None


Test patch: