We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
BankAccount last used should be updated to withdrawal date not processing date.
### Describe the bug
We keep the last date a mandate was used so mandates that have not been used for more than <s>a year</s> three years can be revoked (because that are the sepa rules). However the ‘last used’ date is actually the indicated withdrawal date and not the processing date of the batch

### Expected behaviour
Update with the withdrawal date 

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `website/payments/admin_views.py`
Content:
```
1 """Admin views provided by the payments package"""
2 import csv
3 
4 from django.apps import apps
5 from django.contrib import messages
6 from django.contrib.admin.utils import model_ngettext
7 from django.contrib.admin.views.decorators import staff_member_required
8 from django.contrib.auth.decorators import permission_required
9 from django.db.models import Sum, Count, Min, Max
10 from django.http import HttpResponse
11 from django.core.exceptions import SuspiciousOperation, DisallowedRedirect
12 from django.shortcuts import redirect, get_object_or_404, render
13 from django.utils import timezone
14 from django.utils.text import capfirst
15 from django.utils.decorators import method_decorator
16 from django.utils.http import url_has_allowed_host_and_scheme
17 from django.utils.translation import gettext_lazy as _
18 from django.views import View
19 
20 from payments import services
21 from .models import Payment, Batch, PaymentUser
22 
23 
24 @method_decorator(staff_member_required, name="dispatch")
25 @method_decorator(
26     permission_required("payments.process_payments"), name="dispatch",
27 )
28 class PaymentAdminView(View):
29     """
30     View that creates a payment
31     """
32 
33     def post(self, request, *args, app_label, model_name, payable, **kwargs):
34         if "type" not in request.POST:
35             raise SuspiciousOperation("Missing POST parameters")
36 
37         if "next" in request.POST and not url_has_allowed_host_and_scheme(
38             request.POST.get("next"), allowed_hosts={request.get_host()}
39         ):
40             raise DisallowedRedirect
41 
42         payable_model = apps.get_model(app_label=app_label, model_name=model_name)
43         payable_obj = payable_model.objects.get(pk=payable)
44 
45         result = services.create_payment(
46             payable_obj, self.request.member, request.POST["type"],
47         )
48         payable_obj.save()
49 
50         if result:
51             messages.success(
52                 request, _("Successfully paid %s.") % model_ngettext(payable_obj, 1),
53             )
54         else:
55             messages.error(
56                 request, _("Could not pay %s.") % model_ngettext(payable_obj, 1),
57             )
58             return redirect(f"admin:{app_label}_{model_name}_change", payable_obj.pk)
59 
60         if "next" in request.POST:
61             return redirect(request.POST["next"])
62 
63         return redirect("admin:payments_payment_change", result.pk)
64 
65 
66 @method_decorator(staff_member_required, name="dispatch")
67 @method_decorator(
68     permission_required("payments.process_batches"), name="dispatch",
69 )
70 class BatchProcessAdminView(View):
71     """
72     View that processes a batch
73     """
74 
75     def post(self, request, *args, **kwargs):
76         batch = Batch.objects.get(pk=kwargs["pk"])
77 
78         if "next" in request.POST and not url_has_allowed_host_and_scheme(
79             request.POST.get("next"), allowed_hosts={request.get_host()}
80         ):
81             raise DisallowedRedirect
82 
83         if batch.processed:
84             messages.error(
85                 request, _("{} already processed.").format(model_ngettext(batch, 1))
86             )
87         else:
88             batch.processed = True
89             payments = batch.payments_set.select_related("paid_by")
90             for payment in payments:
91                 bank_account = payment.paid_by.bank_accounts.last()
92                 bank_account.last_used = timezone.now()
93                 bank_account.save()
94 
95             batch.save()
96 
97             services.send_tpay_batch_processing_emails(batch)
98 
99             messages.success(
100                 request,
101                 _("Successfully processed {}.").format(model_ngettext(batch, 1)),
102             )
103 
104         if "next" in request.POST:
105             return redirect(request.POST["next"])
106 
107         return redirect("admin:payments_batch_change", kwargs["pk"])
108 
109 
110 @method_decorator(staff_member_required, name="dispatch")
111 @method_decorator(
112     permission_required("payments.process_batches"), name="dispatch",
113 )
114 class BatchExportAdminView(View):
115     """
116     View that exports a batch
117     """
118 
119     def post(self, request, *args, **kwargs):
120         batch = Batch.objects.get(pk=kwargs["pk"])
121 
122         response = HttpResponse(content_type="text/csv")
123         response["Content-Disposition"] = 'attachment;filename="batch.csv"'
124         writer = csv.writer(response)
125         headers = [
126             _("Account holder"),
127             _("IBAN"),
128             _("Mandate Reference"),
129             _("Amount"),
130             _("Description"),
131             _("Mandate Date"),
132         ]
133         writer.writerow([capfirst(x) for x in headers])
134 
135         member_rows = batch.payments_set.values("paid_by").annotate(total=Sum("amount"))
136 
137         for row in member_rows:
138             member = PaymentUser.objects.get(id=row["paid_by"])
139             bankaccount = member.bank_accounts.last()
140             writer.writerow(
141                 [
142                     member.get_full_name(),
143                     bankaccount.iban,
144                     bankaccount.mandate_no,
145                     f"{row['total']:.2f}",
146                     batch.description,
147                     bankaccount.valid_from,
148                 ]
149             )
150         return response
151 
152 
153 @method_decorator(staff_member_required, name="dispatch")
154 @method_decorator(
155     permission_required("payments.process_batches"), name="dispatch",
156 )
157 class BatchTopicExportAdminView(View):
158     """
159     View that exports a batch per topic
160     """
161 
162     def post(self, request, *args, **kwargs):
163         batch = Batch.objects.get(pk=kwargs["pk"])
164 
165         response = HttpResponse(content_type="text/csv")
166         response["Content-Disposition"] = 'attachment;filename="batch-topic.csv"'
167         writer = csv.writer(response)
168         headers = [
169             _("Topic"),
170             _("No. of payments"),
171             _("First payment"),
172             _("Last payment"),
173             _("Total amount"),
174         ]
175         writer.writerow([capfirst(x) for x in headers])
176 
177         topic_rows = (
178             batch.payments_set.values("topic")
179             .annotate(
180                 total=Sum("amount"),
181                 count=Count("paid_by"),
182                 min_date=Min("created_at"),
183                 max_date=Max("created_at"),
184             )
185             .order_by("topic")
186         )
187 
188         for row in topic_rows:
189             writer.writerow(
190                 [
191                     row["topic"],
192                     row["count"],
193                     timezone.localtime(row["min_date"]).date(),
194                     timezone.localtime(row["max_date"]).date(),
195                     f"{row['total']:.2f}",
196                 ]
197             )
198         return response
199 
200 
201 @method_decorator(staff_member_required, name="dispatch")
202 @method_decorator(
203     permission_required("payments.process_batches"), name="dispatch",
204 )
205 class BatchTopicDescriptionAdminView(View):
206     """
207     Shows the topic export as plain text
208     """
209 
210     template_name = "admin/payments/batch_topic.html"
211 
212     def post(self, request, *args, **kwargs):
213         context = {}
214         batch = get_object_or_404(Batch, pk=kwargs["pk"])
215         topic_rows = (
216             batch.payments_set.values("topic")
217             .annotate(
218                 total=Sum("amount"),
219                 count=Count("paid_by"),
220                 min_date=Min("created_at"),
221                 max_date=Max("created_at"),
222             )
223             .order_by("topic")
224         )
225 
226         description = f"Batch {batch.id} - {batch.processing_date if batch.processing_date else timezone.now().date()}:\n"
227         for row in topic_rows:
228             description += f"- {row['topic']} ({row['count']}x) [{timezone.localtime(row['min_date']).date()} -- {timezone.localtime(row['max_date']).date()}], total €{row['total']:.2f}\n"
229         description += f"\n{batch.description}"
230 
231         context["batch"] = batch
232         context["description"] = description
233         return render(request, self.template_name, context)
234 
235 
236 @method_decorator(staff_member_required, name="dispatch")
237 @method_decorator(
238     permission_required("payments.process_batches"), name="dispatch",
239 )
240 class BatchNewFilledAdminView(View):
241     """
242     View that adds a new batch filled with all payments that where not already in a batch.
243     """
244 
245     def get(self, request, *args, **kwargs):
246         batch = Batch()
247         batch.save()
248 
249         payments = Payment.objects.filter(type=Payment.TPAY, batch=None,)
250 
251         payments.update(batch=batch)
252 
253         return redirect("admin:payments_batch_change", object_id=batch.id)
254
```

Path: `website/payments/services.py`
Content:
```
1 """The services defined by the payments package"""
2 import datetime
3 from typing import Union
4 
5 from django.conf import settings
6 from django.db.models import QuerySet, Q, Sum
7 from django.urls import reverse
8 from django.utils import timezone, translation
9 from django.utils.translation import gettext_lazy as _
10 
11 from members.models import Member
12 from registrations.emails import _send_email
13 from .exceptions import PaymentError
14 from .models import Payment, BankAccount, Payable, PaymentUser
15 
16 
17 def create_payment(
18     payable: Payable,
19     processed_by: Member,
20     pay_type: Union[Payment.CASH, Payment.CARD, Payment.WIRE, Payment.TPAY],
21 ) -> Payment:
22     """
23     Create a new payment from a payable object
24 
25     :param payable: Payable object
26     :param processed_by: PaymentUser that processed this payment
27     :param pay_type: Payment type
28     :return: Payment object
29     """
30     payer = (
31         PaymentUser.objects.get(pk=payable.payment_payer.pk)
32         if payable.payment_payer
33         else None
34     )
35 
36     if pay_type == Payment.TPAY and not payer.tpay_enabled:
37         raise PaymentError(_("This user does not have Thalia Pay enabled"))
38 
39     if payable.payment is not None:
40         payable.payment.amount = payable.payment_amount
41         payable.payment.notes = payable.payment_notes
42         payable.payment.topic = payable.payment_topic
43         payable.payment.paid_by = payer
44         payable.payment.processed_by = processed_by
45         payable.payment.type = pay_type
46         payable.payment.save()
47     else:
48         payable.payment = Payment.objects.create(
49             processed_by=processed_by,
50             amount=payable.payment_amount,
51             notes=payable.payment_notes,
52             topic=payable.payment_topic,
53             paid_by=payer,
54             type=pay_type,
55         )
56     return payable.payment
57 
58 
59 def delete_payment(payable: Payable):
60     """
61     Removes a payment from a payable object
62     :param payable: Payable object
63     :return:
64     """
65     payment = payable.payment
66     if payment.created_at < timezone.now() - timezone.timedelta(
67         seconds=settings.PAYMENT_CHANGE_WINDOW
68     ):
69         raise PaymentError(_("You are not authorized to delete this payment."))
70     if payment.batch and payment.batch.processed:
71         raise PaymentError(_("This payment has already been processed."))
72 
73     payable.payment = None
74     payable.save()
75     payment.delete()
76 
77 
78 def update_last_used(queryset: QuerySet, date: datetime.date = None) -> int:
79     """
80     Update the last used field of a BankAccount queryset
81 
82     :param queryset: Queryset of BankAccounts
83     :param date: date to set last_used to
84     :return: number of affected rows
85     """
86     if not date:
87         date = timezone.now().date()
88 
89     result = queryset.filter(
90         (Q(valid_from__gte=timezone.now()) & Q(valid_until__lt=timezone.now()))
91         | Q(valid_until=None)
92     ).update(last_used=date)
93     return result
94 
95 
96 def revoke_old_mandates() -> int:
97     """
98     Revokes all mandates that have not been used for 36 months or more
99     :return: number of affected rows
100     """
101     return BankAccount.objects.filter(
102         last_used__lte=(timezone.now() - timezone.timedelta(days=36 * 30))
103     ).update(valid_until=timezone.now().date())
104 
105 
106 def send_tpay_batch_processing_emails(batch):
107     """Sends withdrawal notice emails to all members in a batch"""
108     member_payments = batch.payments_set.values("paid_by").annotate(total=Sum("amount"))
109     for member_row in member_payments:
110         member = PaymentUser.objects.get(pk=member_row["paid_by"])
111         total_amount = member_row["total"]
112 
113         with translation.override(member.profile.language):
114             _send_email(
115                 member.email,
116                 _("Thalia Pay withdrawal notice"),
117                 "payments/email/tpay_withdrawal_notice_mail.txt",
118                 {
119                     "name": member.get_full_name(),
120                     "batch": batch,
121                     "bank_account": member.bank_accounts.filter(
122                         mandate_no__isnull=False
123                     ).last(),
124                     "creditor_id": settings.SEPA_CREDITOR_ID,
125                     "payments": batch.payments_set.filter(paid_by=member),
126                     "total_amount": total_amount,
127                     "payments_url": (
128                         settings.BASE_URL + reverse("payments:payment-list",)
129                     ),
130                 },
131             )
132     return len(member_payments)
133
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/website/payments/admin_views.py b/website/payments/admin_views.py
--- a/website/payments/admin_views.py
+++ b/website/payments/admin_views.py
@@ -85,17 +85,7 @@
                 request, _("{} already processed.").format(model_ngettext(batch, 1))
             )
         else:
-            batch.processed = True
-            payments = batch.payments_set.select_related("paid_by")
-            for payment in payments:
-                bank_account = payment.paid_by.bank_accounts.last()
-                bank_account.last_used = timezone.now()
-                bank_account.save()
-
-            batch.save()
-
-            services.send_tpay_batch_processing_emails(batch)
-
+            services.process_batch(batch)
             messages.success(
                 request,
                 _("Successfully processed {}.").format(model_ngettext(batch, 1)),
diff --git a/website/payments/services.py b/website/payments/services.py
--- a/website/payments/services.py
+++ b/website/payments/services.py
@@ -103,6 +103,26 @@
     ).update(valid_until=timezone.now().date())
 
 
+def process_batch(batch):
+    """
+    Processes a Thalia Pay batch
+
+    :param batch: the batch to be processed
+    :return:
+    """
+    batch.processed = True
+
+    payments = batch.payments_set.select_related("paid_by")
+    for payment in payments:
+        bank_account = payment.paid_by.bank_accounts.last()
+        bank_account.last_used = batch.withdrawal_date
+        bank_account.save()
+
+    batch.save()
+
+    send_tpay_batch_processing_emails(batch)
+
+
 def send_tpay_batch_processing_emails(batch):
     """Sends withdrawal notice emails to all members in a batch"""
     member_payments = batch.payments_set.values("paid_by").annotate(total=Sum("amount"))

{"golden_diff": "diff --git a/website/payments/admin_views.py b/website/payments/admin_views.py\n--- a/website/payments/admin_views.py\n+++ b/website/payments/admin_views.py\n@@ -85,17 +85,7 @@\n                 request, _(\"{} already processed.\").format(model_ngettext(batch, 1))\n             )\n         else:\n-            batch.processed = True\n-            payments = batch.payments_set.select_related(\"paid_by\")\n-            for payment in payments:\n-                bank_account = payment.paid_by.bank_accounts.last()\n-                bank_account.last_used = timezone.now()\n-                bank_account.save()\n-\n-            batch.save()\n-\n-            services.send_tpay_batch_processing_emails(batch)\n-\n+            services.process_batch(batch)\n             messages.success(\n                 request,\n                 _(\"Successfully processed {}.\").format(model_ngettext(batch, 1)),\ndiff --git a/website/payments/services.py b/website/payments/services.py\n--- a/website/payments/services.py\n+++ b/website/payments/services.py\n@@ -103,6 +103,26 @@\n     ).update(valid_until=timezone.now().date())\n \n \n+def process_batch(batch):\n+    \"\"\"\n+    Processes a Thalia Pay batch\n+\n+    :param batch: the batch to be processed\n+    :return:\n+    \"\"\"\n+    batch.processed = True\n+\n+    payments = batch.payments_set.select_related(\"paid_by\")\n+    for payment in payments:\n+        bank_account = payment.paid_by.bank_accounts.last()\n+        bank_account.last_used = batch.withdrawal_date\n+        bank_account.save()\n+\n+    batch.save()\n+\n+    send_tpay_batch_processing_emails(batch)\n+\n+\n def send_tpay_batch_processing_emails(batch):\n     \"\"\"Sends withdrawal notice emails to all members in a batch\"\"\"\n     member_payments = batch.payments_set.values(\"paid_by\").annotate(total=Sum(\"amount\"))\n", "issue": "BankAccount last used should be updated to withdrawal date not processing date.\n### Describe the bug\r\nWe keep the last date a mandate was used so mandates that have not been used for more than <s>a year</s> three years can be revoked (because that are the sepa rules). However the \u2018last used\u2019 date is actually the indicated withdrawal date and not the processing date of the batch\r\n\r\n### Expected behaviour\r\nUpdate with the withdrawal date \n", "before_files": [{"content": "\"\"\"Admin views provided by the payments package\"\"\"\nimport csv\n\nfrom django.apps import apps\nfrom django.contrib import messages\nfrom django.contrib.admin.utils import model_ngettext\nfrom django.contrib.admin.views.decorators import staff_member_required\nfrom django.contrib.auth.decorators import permission_required\nfrom django.db.models import Sum, Count, Min, Max\nfrom django.http import HttpResponse\nfrom django.core.exceptions import SuspiciousOperation, DisallowedRedirect\nfrom django.shortcuts import redirect, get_object_or_404, render\nfrom django.utils import timezone\nfrom django.utils.text import capfirst\nfrom django.utils.decorators import method_decorator\nfrom django.utils.http import url_has_allowed_host_and_scheme\nfrom django.utils.translation import gettext_lazy as _\nfrom django.views import View\n\nfrom payments import services\nfrom .models import Payment, Batch, PaymentUser\n\n\n@method_decorator(staff_member_required, name=\"dispatch\")\n@method_decorator(\n    permission_required(\"payments.process_payments\"), name=\"dispatch\",\n)\nclass PaymentAdminView(View):\n    \"\"\"\n    View that creates a payment\n    \"\"\"\n\n    def post(self, request, *args, app_label, model_name, payable, **kwargs):\n        if \"type\" not in request.POST:\n            raise SuspiciousOperation(\"Missing POST parameters\")\n\n        if \"next\" in request.POST and not url_has_allowed_host_and_scheme(\n            request.POST.get(\"next\"), allowed_hosts={request.get_host()}\n        ):\n            raise DisallowedRedirect\n\n        payable_model = apps.get_model(app_label=app_label, model_name=model_name)\n        payable_obj = payable_model.objects.get(pk=payable)\n\n        result = services.create_payment(\n            payable_obj, self.request.member, request.POST[\"type\"],\n        )\n        payable_obj.save()\n\n        if result:\n            messages.success(\n                request, _(\"Successfully paid %s.\") % model_ngettext(payable_obj, 1),\n            )\n        else:\n            messages.error(\n                request, _(\"Could not pay %s.\") % model_ngettext(payable_obj, 1),\n            )\n            return redirect(f\"admin:{app_label}_{model_name}_change\", payable_obj.pk)\n\n        if \"next\" in request.POST:\n            return redirect(request.POST[\"next\"])\n\n        return redirect(\"admin:payments_payment_change\", result.pk)\n\n\n@method_decorator(staff_member_required, name=\"dispatch\")\n@method_decorator(\n    permission_required(\"payments.process_batches\"), name=\"dispatch\",\n)\nclass BatchProcessAdminView(View):\n    \"\"\"\n    View that processes a batch\n    \"\"\"\n\n    def post(self, request, *args, **kwargs):\n        batch = Batch.objects.get(pk=kwargs[\"pk\"])\n\n        if \"next\" in request.POST and not url_has_allowed_host_and_scheme(\n            request.POST.get(\"next\"), allowed_hosts={request.get_host()}\n        ):\n            raise DisallowedRedirect\n\n        if batch.processed:\n            messages.error(\n                request, _(\"{} already processed.\").format(model_ngettext(batch, 1))\n            )\n        else:\n            batch.processed = True\n            payments = batch.payments_set.select_related(\"paid_by\")\n            for payment in payments:\n                bank_account = payment.paid_by.bank_accounts.last()\n                bank_account.last_used = timezone.now()\n                bank_account.save()\n\n            batch.save()\n\n            services.send_tpay_batch_processing_emails(batch)\n\n            messages.success(\n                request,\n                _(\"Successfully processed {}.\").format(model_ngettext(batch, 1)),\n            )\n\n        if \"next\" in request.POST:\n            return redirect(request.POST[\"next\"])\n\n        return redirect(\"admin:payments_batch_change\", kwargs[\"pk\"])\n\n\n@method_decorator(staff_member_required, name=\"dispatch\")\n@method_decorator(\n    permission_required(\"payments.process_batches\"), name=\"dispatch\",\n)\nclass BatchExportAdminView(View):\n    \"\"\"\n    View that exports a batch\n    \"\"\"\n\n    def post(self, request, *args, **kwargs):\n        batch = Batch.objects.get(pk=kwargs[\"pk\"])\n\n        response = HttpResponse(content_type=\"text/csv\")\n        response[\"Content-Disposition\"] = 'attachment;filename=\"batch.csv\"'\n        writer = csv.writer(response)\n        headers = [\n            _(\"Account holder\"),\n            _(\"IBAN\"),\n            _(\"Mandate Reference\"),\n            _(\"Amount\"),\n            _(\"Description\"),\n            _(\"Mandate Date\"),\n        ]\n        writer.writerow([capfirst(x) for x in headers])\n\n        member_rows = batch.payments_set.values(\"paid_by\").annotate(total=Sum(\"amount\"))\n\n        for row in member_rows:\n            member = PaymentUser.objects.get(id=row[\"paid_by\"])\n            bankaccount = member.bank_accounts.last()\n            writer.writerow(\n                [\n                    member.get_full_name(),\n                    bankaccount.iban,\n                    bankaccount.mandate_no,\n                    f\"{row['total']:.2f}\",\n                    batch.description,\n                    bankaccount.valid_from,\n                ]\n            )\n        return response\n\n\n@method_decorator(staff_member_required, name=\"dispatch\")\n@method_decorator(\n    permission_required(\"payments.process_batches\"), name=\"dispatch\",\n)\nclass BatchTopicExportAdminView(View):\n    \"\"\"\n    View that exports a batch per topic\n    \"\"\"\n\n    def post(self, request, *args, **kwargs):\n        batch = Batch.objects.get(pk=kwargs[\"pk\"])\n\n        response = HttpResponse(content_type=\"text/csv\")\n        response[\"Content-Disposition\"] = 'attachment;filename=\"batch-topic.csv\"'\n        writer = csv.writer(response)\n        headers = [\n            _(\"Topic\"),\n            _(\"No. of payments\"),\n            _(\"First payment\"),\n            _(\"Last payment\"),\n            _(\"Total amount\"),\n        ]\n        writer.writerow([capfirst(x) for x in headers])\n\n        topic_rows = (\n            batch.payments_set.values(\"topic\")\n            .annotate(\n                total=Sum(\"amount\"),\n                count=Count(\"paid_by\"),\n                min_date=Min(\"created_at\"),\n                max_date=Max(\"created_at\"),\n            )\n            .order_by(\"topic\")\n        )\n\n        for row in topic_rows:\n            writer.writerow(\n                [\n                    row[\"topic\"],\n                    row[\"count\"],\n                    timezone.localtime(row[\"min_date\"]).date(),\n                    timezone.localtime(row[\"max_date\"]).date(),\n                    f\"{row['total']:.2f}\",\n                ]\n            )\n        return response\n\n\n@method_decorator(staff_member_required, name=\"dispatch\")\n@method_decorator(\n    permission_required(\"payments.process_batches\"), name=\"dispatch\",\n)\nclass BatchTopicDescriptionAdminView(View):\n    \"\"\"\n    Shows the topic export as plain text\n    \"\"\"\n\n    template_name = \"admin/payments/batch_topic.html\"\n\n    def post(self, request, *args, **kwargs):\n        context = {}\n        batch = get_object_or_404(Batch, pk=kwargs[\"pk\"])\n        topic_rows = (\n            batch.payments_set.values(\"topic\")\n            .annotate(\n                total=Sum(\"amount\"),\n                count=Count(\"paid_by\"),\n                min_date=Min(\"created_at\"),\n                max_date=Max(\"created_at\"),\n            )\n            .order_by(\"topic\")\n        )\n\n        description = f\"Batch {batch.id} - {batch.processing_date if batch.processing_date else timezone.now().date()}:\\n\"\n        for row in topic_rows:\n            description += f\"- {row['topic']} ({row['count']}x) [{timezone.localtime(row['min_date']).date()} -- {timezone.localtime(row['max_date']).date()}], total \u20ac{row['total']:.2f}\\n\"\n        description += f\"\\n{batch.description}\"\n\n        context[\"batch\"] = batch\n        context[\"description\"] = description\n        return render(request, self.template_name, context)\n\n\n@method_decorator(staff_member_required, name=\"dispatch\")\n@method_decorator(\n    permission_required(\"payments.process_batches\"), name=\"dispatch\",\n)\nclass BatchNewFilledAdminView(View):\n    \"\"\"\n    View that adds a new batch filled with all payments that where not already in a batch.\n    \"\"\"\n\n    def get(self, request, *args, **kwargs):\n        batch = Batch()\n        batch.save()\n\n        payments = Payment.objects.filter(type=Payment.TPAY, batch=None,)\n\n        payments.update(batch=batch)\n\n        return redirect(\"admin:payments_batch_change\", object_id=batch.id)\n", "path": "website/payments/admin_views.py"}, {"content": "\"\"\"The services defined by the payments package\"\"\"\nimport datetime\nfrom typing import Union\n\nfrom django.conf import settings\nfrom django.db.models import QuerySet, Q, Sum\nfrom django.urls import reverse\nfrom django.utils import timezone, translation\nfrom django.utils.translation import gettext_lazy as _\n\nfrom members.models import Member\nfrom registrations.emails import _send_email\nfrom .exceptions import PaymentError\nfrom .models import Payment, BankAccount, Payable, PaymentUser\n\n\ndef create_payment(\n    payable: Payable,\n    processed_by: Member,\n    pay_type: Union[Payment.CASH, Payment.CARD, Payment.WIRE, Payment.TPAY],\n) -> Payment:\n    \"\"\"\n    Create a new payment from a payable object\n\n    :param payable: Payable object\n    :param processed_by: PaymentUser that processed this payment\n    :param pay_type: Payment type\n    :return: Payment object\n    \"\"\"\n    payer = (\n        PaymentUser.objects.get(pk=payable.payment_payer.pk)\n        if payable.payment_payer\n        else None\n    )\n\n    if pay_type == Payment.TPAY and not payer.tpay_enabled:\n        raise PaymentError(_(\"This user does not have Thalia Pay enabled\"))\n\n    if payable.payment is not None:\n        payable.payment.amount = payable.payment_amount\n        payable.payment.notes = payable.payment_notes\n        payable.payment.topic = payable.payment_topic\n        payable.payment.paid_by = payer\n        payable.payment.processed_by = processed_by\n        payable.payment.type = pay_type\n        payable.payment.save()\n    else:\n        payable.payment = Payment.objects.create(\n            processed_by=processed_by,\n            amount=payable.payment_amount,\n            notes=payable.payment_notes,\n            topic=payable.payment_topic,\n            paid_by=payer,\n            type=pay_type,\n        )\n    return payable.payment\n\n\ndef delete_payment(payable: Payable):\n    \"\"\"\n    Removes a payment from a payable object\n    :param payable: Payable object\n    :return:\n    \"\"\"\n    payment = payable.payment\n    if payment.created_at < timezone.now() - timezone.timedelta(\n        seconds=settings.PAYMENT_CHANGE_WINDOW\n    ):\n        raise PaymentError(_(\"You are not authorized to delete this payment.\"))\n    if payment.batch and payment.batch.processed:\n        raise PaymentError(_(\"This payment has already been processed.\"))\n\n    payable.payment = None\n    payable.save()\n    payment.delete()\n\n\ndef update_last_used(queryset: QuerySet, date: datetime.date = None) -> int:\n    \"\"\"\n    Update the last used field of a BankAccount queryset\n\n    :param queryset: Queryset of BankAccounts\n    :param date: date to set last_used to\n    :return: number of affected rows\n    \"\"\"\n    if not date:\n        date = timezone.now().date()\n\n    result = queryset.filter(\n        (Q(valid_from__gte=timezone.now()) & Q(valid_until__lt=timezone.now()))\n        | Q(valid_until=None)\n    ).update(last_used=date)\n    return result\n\n\ndef revoke_old_mandates() -> int:\n    \"\"\"\n    Revokes all mandates that have not been used for 36 months or more\n    :return: number of affected rows\n    \"\"\"\n    return BankAccount.objects.filter(\n        last_used__lte=(timezone.now() - timezone.timedelta(days=36 * 30))\n    ).update(valid_until=timezone.now().date())\n\n\ndef send_tpay_batch_processing_emails(batch):\n    \"\"\"Sends withdrawal notice emails to all members in a batch\"\"\"\n    member_payments = batch.payments_set.values(\"paid_by\").annotate(total=Sum(\"amount\"))\n    for member_row in member_payments:\n        member = PaymentUser.objects.get(pk=member_row[\"paid_by\"])\n        total_amount = member_row[\"total\"]\n\n        with translation.override(member.profile.language):\n            _send_email(\n                member.email,\n                _(\"Thalia Pay withdrawal notice\"),\n                \"payments/email/tpay_withdrawal_notice_mail.txt\",\n                {\n                    \"name\": member.get_full_name(),\n                    \"batch\": batch,\n                    \"bank_account\": member.bank_accounts.filter(\n                        mandate_no__isnull=False\n                    ).last(),\n                    \"creditor_id\": settings.SEPA_CREDITOR_ID,\n                    \"payments\": batch.payments_set.filter(paid_by=member),\n                    \"total_amount\": total_amount,\n                    \"payments_url\": (\n                        settings.BASE_URL + reverse(\"payments:payment-list\",)\n                    ),\n                },\n            )\n    return len(member_payments)\n", "path": "website/payments/services.py"}], "after_files": [{"content": "\"\"\"Admin views provided by the payments package\"\"\"\nimport csv\n\nfrom django.apps import apps\nfrom django.contrib import messages\nfrom django.contrib.admin.utils import model_ngettext\nfrom django.contrib.admin.views.decorators import staff_member_required\nfrom django.contrib.auth.decorators import permission_required\nfrom django.db.models import Sum, Count, Min, Max\nfrom django.http import HttpResponse\nfrom django.core.exceptions import SuspiciousOperation, DisallowedRedirect\nfrom django.shortcuts import redirect, get_object_or_404, render\nfrom django.utils import timezone\nfrom django.utils.text import capfirst\nfrom django.utils.decorators import method_decorator\nfrom django.utils.http import url_has_allowed_host_and_scheme\nfrom django.utils.translation import gettext_lazy as _\nfrom django.views import View\n\nfrom payments import services\nfrom .models import Payment, Batch, PaymentUser\n\n\n@method_decorator(staff_member_required, name=\"dispatch\")\n@method_decorator(\n    permission_required(\"payments.process_payments\"), name=\"dispatch\",\n)\nclass PaymentAdminView(View):\n    \"\"\"\n    View that creates a payment\n    \"\"\"\n\n    def post(self, request, *args, app_label, model_name, payable, **kwargs):\n        if \"type\" not in request.POST:\n            raise SuspiciousOperation(\"Missing POST parameters\")\n\n        if \"next\" in request.POST and not url_has_allowed_host_and_scheme(\n            request.POST.get(\"next\"), allowed_hosts={request.get_host()}\n        ):\n            raise DisallowedRedirect\n\n        payable_model = apps.get_model(app_label=app_label, model_name=model_name)\n        payable_obj = payable_model.objects.get(pk=payable)\n\n        result = services.create_payment(\n            payable_obj, self.request.member, request.POST[\"type\"],\n        )\n        payable_obj.save()\n\n        if result:\n            messages.success(\n                request, _(\"Successfully paid %s.\") % model_ngettext(payable_obj, 1),\n            )\n        else:\n            messages.error(\n                request, _(\"Could not pay %s.\") % model_ngettext(payable_obj, 1),\n            )\n            return redirect(f\"admin:{app_label}_{model_name}_change\", payable_obj.pk)\n\n        if \"next\" in request.POST:\n            return redirect(request.POST[\"next\"])\n\n        return redirect(\"admin:payments_payment_change\", result.pk)\n\n\n@method_decorator(staff_member_required, name=\"dispatch\")\n@method_decorator(\n    permission_required(\"payments.process_batches\"), name=\"dispatch\",\n)\nclass BatchProcessAdminView(View):\n    \"\"\"\n    View that processes a batch\n    \"\"\"\n\n    def post(self, request, *args, **kwargs):\n        batch = Batch.objects.get(pk=kwargs[\"pk\"])\n\n        if \"next\" in request.POST and not url_has_allowed_host_and_scheme(\n            request.POST.get(\"next\"), allowed_hosts={request.get_host()}\n        ):\n            raise DisallowedRedirect\n\n        if batch.processed:\n            messages.error(\n                request, _(\"{} already processed.\").format(model_ngettext(batch, 1))\n            )\n        else:\n            services.process_batch(batch)\n            messages.success(\n                request,\n                _(\"Successfully processed {}.\").format(model_ngettext(batch, 1)),\n            )\n\n        if \"next\" in request.POST:\n            return redirect(request.POST[\"next\"])\n\n        return redirect(\"admin:payments_batch_change\", kwargs[\"pk\"])\n\n\n@method_decorator(staff_member_required, name=\"dispatch\")\n@method_decorator(\n    permission_required(\"payments.process_batches\"), name=\"dispatch\",\n)\nclass BatchExportAdminView(View):\n    \"\"\"\n    View that exports a batch\n    \"\"\"\n\n    def post(self, request, *args, **kwargs):\n        batch = Batch.objects.get(pk=kwargs[\"pk\"])\n\n        response = HttpResponse(content_type=\"text/csv\")\n        response[\"Content-Disposition\"] = 'attachment;filename=\"batch.csv\"'\n        writer = csv.writer(response)\n        headers = [\n            _(\"Account holder\"),\n            _(\"IBAN\"),\n            _(\"Mandate Reference\"),\n            _(\"Amount\"),\n            _(\"Description\"),\n            _(\"Mandate Date\"),\n        ]\n        writer.writerow([capfirst(x) for x in headers])\n\n        member_rows = batch.payments_set.values(\"paid_by\").annotate(total=Sum(\"amount\"))\n\n        for row in member_rows:\n            member = PaymentUser.objects.get(id=row[\"paid_by\"])\n            bankaccount = member.bank_accounts.last()\n            writer.writerow(\n                [\n                    member.get_full_name(),\n                    bankaccount.iban,\n                    bankaccount.mandate_no,\n                    f\"{row['total']:.2f}\",\n                    batch.description,\n                    bankaccount.valid_from,\n                ]\n            )\n        return response\n\n\n@method_decorator(staff_member_required, name=\"dispatch\")\n@method_decorator(\n    permission_required(\"payments.process_batches\"), name=\"dispatch\",\n)\nclass BatchTopicExportAdminView(View):\n    \"\"\"\n    View that exports a batch per topic\n    \"\"\"\n\n    def post(self, request, *args, **kwargs):\n        batch = Batch.objects.get(pk=kwargs[\"pk\"])\n\n        response = HttpResponse(content_type=\"text/csv\")\n        response[\"Content-Disposition\"] = 'attachment;filename=\"batch-topic.csv\"'\n        writer = csv.writer(response)\n        headers = [\n            _(\"Topic\"),\n            _(\"No. of payments\"),\n            _(\"First payment\"),\n            _(\"Last payment\"),\n            _(\"Total amount\"),\n        ]\n        writer.writerow([capfirst(x) for x in headers])\n\n        topic_rows = (\n            batch.payments_set.values(\"topic\")\n            .annotate(\n                total=Sum(\"amount\"),\n                count=Count(\"paid_by\"),\n                min_date=Min(\"created_at\"),\n                max_date=Max(\"created_at\"),\n            )\n            .order_by(\"topic\")\n        )\n\n        for row in topic_rows:\n            writer.writerow(\n                [\n                    row[\"topic\"],\n                    row[\"count\"],\n                    timezone.localtime(row[\"min_date\"]).date(),\n                    timezone.localtime(row[\"max_date\"]).date(),\n                    f\"{row['total']:.2f}\",\n                ]\n            )\n        return response\n\n\n@method_decorator(staff_member_required, name=\"dispatch\")\n@method_decorator(\n    permission_required(\"payments.process_batches\"), name=\"dispatch\",\n)\nclass BatchTopicDescriptionAdminView(View):\n    \"\"\"\n    Shows the topic export as plain text\n    \"\"\"\n\n    template_name = \"admin/payments/batch_topic.html\"\n\n    def post(self, request, *args, **kwargs):\n        context = {}\n        batch = get_object_or_404(Batch, pk=kwargs[\"pk\"])\n        topic_rows = (\n            batch.payments_set.values(\"topic\")\n            .annotate(\n                total=Sum(\"amount\"),\n                count=Count(\"paid_by\"),\n                min_date=Min(\"created_at\"),\n                max_date=Max(\"created_at\"),\n            )\n            .order_by(\"topic\")\n        )\n\n        description = f\"Batch {batch.id} - {batch.processing_date if batch.processing_date else timezone.now().date()}:\\n\"\n        for row in topic_rows:\n            description += f\"- {row['topic']} ({row['count']}x) [{timezone.localtime(row['min_date']).date()} -- {timezone.localtime(row['max_date']).date()}], total \u20ac{row['total']:.2f}\\n\"\n        description += f\"\\n{batch.description}\"\n\n        context[\"batch\"] = batch\n        context[\"description\"] = description\n        return render(request, self.template_name, context)\n\n\n@method_decorator(staff_member_required, name=\"dispatch\")\n@method_decorator(\n    permission_required(\"payments.process_batches\"), name=\"dispatch\",\n)\nclass BatchNewFilledAdminView(View):\n    \"\"\"\n    View that adds a new batch filled with all payments that where not already in a batch.\n    \"\"\"\n\n    def get(self, request, *args, **kwargs):\n        batch = Batch()\n        batch.save()\n\n        payments = Payment.objects.filter(type=Payment.TPAY, batch=None,)\n\n        payments.update(batch=batch)\n\n        return redirect(\"admin:payments_batch_change\", object_id=batch.id)\n", "path": "website/payments/admin_views.py"}, {"content": "\"\"\"The services defined by the payments package\"\"\"\nimport datetime\nfrom typing import Union\n\nfrom django.conf import settings\nfrom django.db.models import QuerySet, Q, Sum\nfrom django.urls import reverse\nfrom django.utils import timezone, translation\nfrom django.utils.translation import gettext_lazy as _\n\nfrom members.models import Member\nfrom registrations.emails import _send_email\nfrom .exceptions import PaymentError\nfrom .models import Payment, BankAccount, Payable, PaymentUser\n\n\ndef create_payment(\n    payable: Payable,\n    processed_by: Member,\n    pay_type: Union[Payment.CASH, Payment.CARD, Payment.WIRE, Payment.TPAY],\n) -> Payment:\n    \"\"\"\n    Create a new payment from a payable object\n\n    :param payable: Payable object\n    :param processed_by: PaymentUser that processed this payment\n    :param pay_type: Payment type\n    :return: Payment object\n    \"\"\"\n    payer = (\n        PaymentUser.objects.get(pk=payable.payment_payer.pk)\n        if payable.payment_payer\n        else None\n    )\n\n    if pay_type == Payment.TPAY and not payer.tpay_enabled:\n        raise PaymentError(_(\"This user does not have Thalia Pay enabled\"))\n\n    if payable.payment is not None:\n        payable.payment.amount = payable.payment_amount\n        payable.payment.notes = payable.payment_notes\n        payable.payment.topic = payable.payment_topic\n        payable.payment.paid_by = payer\n        payable.payment.processed_by = processed_by\n        payable.payment.type = pay_type\n        payable.payment.save()\n    else:\n        payable.payment = Payment.objects.create(\n            processed_by=processed_by,\n            amount=payable.payment_amount,\n            notes=payable.payment_notes,\n            topic=payable.payment_topic,\n            paid_by=payer,\n            type=pay_type,\n        )\n    return payable.payment\n\n\ndef delete_payment(payable: Payable):\n    \"\"\"\n    Removes a payment from a payable object\n    :param payable: Payable object\n    :return:\n    \"\"\"\n    payment = payable.payment\n    if payment.created_at < timezone.now() - timezone.timedelta(\n        seconds=settings.PAYMENT_CHANGE_WINDOW\n    ):\n        raise PaymentError(_(\"You are not authorized to delete this payment.\"))\n    if payment.batch and payment.batch.processed:\n        raise PaymentError(_(\"This payment has already been processed.\"))\n\n    payable.payment = None\n    payable.save()\n    payment.delete()\n\n\ndef update_last_used(queryset: QuerySet, date: datetime.date = None) -> int:\n    \"\"\"\n    Update the last used field of a BankAccount queryset\n\n    :param queryset: Queryset of BankAccounts\n    :param date: date to set last_used to\n    :return: number of affected rows\n    \"\"\"\n    if not date:\n        date = timezone.now().date()\n\n    result = queryset.filter(\n        (Q(valid_from__gte=timezone.now()) & Q(valid_until__lt=timezone.now()))\n        | Q(valid_until=None)\n    ).update(last_used=date)\n    return result\n\n\ndef revoke_old_mandates() -> int:\n    \"\"\"\n    Revokes all mandates that have not been used for 36 months or more\n    :return: number of affected rows\n    \"\"\"\n    return BankAccount.objects.filter(\n        last_used__lte=(timezone.now() - timezone.timedelta(days=36 * 30))\n    ).update(valid_until=timezone.now().date())\n\n\ndef process_batch(batch):\n    \"\"\"\n    Processes a Thalia Pay batch\n\n    :param batch: the batch to be processed\n    :return:\n    \"\"\"\n    batch.processed = True\n\n    payments = batch.payments_set.select_related(\"paid_by\")\n    for payment in payments:\n        bank_account = payment.paid_by.bank_accounts.last()\n        bank_account.last_used = batch.withdrawal_date\n        bank_account.save()\n\n    batch.save()\n\n    send_tpay_batch_processing_emails(batch)\n\n\ndef send_tpay_batch_processing_emails(batch):\n    \"\"\"Sends withdrawal notice emails to all members in a batch\"\"\"\n    member_payments = batch.payments_set.values(\"paid_by\").annotate(total=Sum(\"amount\"))\n    for member_row in member_payments:\n        member = PaymentUser.objects.get(pk=member_row[\"paid_by\"])\n        total_amount = member_row[\"total\"]\n\n        with translation.override(member.profile.language):\n            _send_email(\n                member.email,\n                _(\"Thalia Pay withdrawal notice\"),\n                \"payments/email/tpay_withdrawal_notice_mail.txt\",\n                {\n                    \"name\": member.get_full_name(),\n                    \"batch\": batch,\n                    \"bank_account\": member.bank_accounts.filter(\n                        mandate_no__isnull=False\n                    ).last(),\n                    \"creditor_id\": settings.SEPA_CREDITOR_ID,\n                    \"payments\": batch.payments_set.filter(paid_by=member),\n                    \"total_amount\": total_amount,\n                    \"payments_url\": (\n                        settings.BASE_URL + reverse(\"payments:payment-list\",)\n                    ),\n                },\n            )\n    return len(member_payments)\n", "path": "website/payments/services.py"}]}