We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Send email with Thalia Pay payments that will be withdrawn on batch processed
### Describe the solution you'd like
When a payment batch is processed, all users that are in that batch should receive an email notifying them that we will withdraw some amount from their bank account.

### Motivation
It is required for the SEPA direct debit mandate

--- 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
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 members.models import Member
21 from payments import services
22 from .models import Payment, Batch
23 
24 
25 @method_decorator(staff_member_required, name="dispatch")
26 @method_decorator(
27     permission_required("payments.process_payments"), name="dispatch",
28 )
29 class PaymentAdminView(View):
30     """
31     View that creates a payment
32     """
33 
34     def post(self, request, *args, app_label, model_name, payable, **kwargs):
35         if "type" not in request.POST:
36             raise SuspiciousOperation("Missing POST parameters")
37 
38         if "next" in request.POST and not url_has_allowed_host_and_scheme(
39             request.POST.get("next"), allowed_hosts={request.get_host()}
40         ):
41             raise DisallowedRedirect
42 
43         payable_model = apps.get_model(app_label=app_label, model_name=model_name)
44         payable_obj = payable_model.objects.get(pk=payable)
45 
46         result = services.create_payment(
47             payable_obj, request.member, request.POST["type"]
48         )
49         payable_obj.save()
50 
51         if result:
52             messages.success(
53                 request, _("Successfully paid %s.") % model_ngettext(payable_obj, 1),
54             )
55         else:
56             messages.error(
57                 request, _("Could not pay %s.") % model_ngettext(payable_obj, 1),
58             )
59             return redirect(f"admin:{app_label}_{model_name}_change", payable_obj.pk)
60 
61         if "next" in request.POST:
62             return redirect(request.POST["next"])
63 
64         return redirect("admin:payments_payment_change", result.pk)
65 
66 
67 @method_decorator(staff_member_required, name="dispatch")
68 @method_decorator(
69     permission_required("payments.process_batches"), name="dispatch",
70 )
71 class BatchProcessAdminView(View):
72     """
73     View that processes a batch
74     """
75 
76     def post(self, request, *args, **kwargs):
77         batch = Batch.objects.get(pk=kwargs["pk"])
78 
79         if "next" in request.POST and not url_has_allowed_host_and_scheme(
80             request.POST.get("next"), allowed_hosts={request.get_host()}
81         ):
82             raise DisallowedRedirect
83 
84         if batch.processed:
85             messages.error(
86                 request, _("{} already processed.").format(model_ngettext(batch, 1))
87             )
88         else:
89             batch.processed = True
90             payments = batch.payments_set.select_related("paid_by")
91             for payment in payments:
92                 bank_account = payment.paid_by.bank_accounts.last()
93                 bank_account.last_used = timezone.now()
94                 bank_account.save()
95 
96             batch.save()
97             messages.success(
98                 request,
99                 _("Successfully processed {}.").format(model_ngettext(batch, 1)),
100             )
101 
102         if "next" in request.POST:
103             return redirect(request.POST["next"])
104 
105         return redirect("admin:payments_batch_change", kwargs["pk"])
106 
107 
108 @method_decorator(staff_member_required, name="dispatch")
109 @method_decorator(
110     permission_required("payments.process_batches"), name="dispatch",
111 )
112 class BatchExportAdminView(View):
113     """
114     View that exports a batch
115     """
116 
117     def post(self, request, *args, **kwargs):
118         batch = Batch.objects.get(pk=kwargs["pk"])
119 
120         response = HttpResponse(content_type="text/csv")
121         response["Content-Disposition"] = 'attachment;filename="batch.csv"'
122         writer = csv.writer(response)
123         headers = [
124             _("Account holder"),
125             _("IBAN"),
126             _("Mandate Reference"),
127             _("Amount"),
128             _("Description"),
129             _("Mandate Date"),
130         ]
131         writer.writerow([capfirst(x) for x in headers])
132 
133         member_rows = batch.payments_set.values("paid_by").annotate(total=Sum("amount"))
134 
135         for row in member_rows:
136             member = Member.objects.get(id=row["paid_by"])
137             bankaccount = member.bank_accounts.last()
138             writer.writerow(
139                 [
140                     member.get_full_name(),
141                     bankaccount.iban,
142                     bankaccount.mandate_no,
143                     f"{row['total']:.2f}",
144                     batch.description,
145                     bankaccount.valid_from,
146                 ]
147             )
148         return response
149 
150 
151 @method_decorator(staff_member_required, name="dispatch")
152 @method_decorator(
153     permission_required("payments.process_batches"), name="dispatch",
154 )
155 class BatchTopicExportAdminView(View):
156     """
157     View that exports a batch per topic
158     """
159 
160     def post(self, request, *args, **kwargs):
161         batch = Batch.objects.get(pk=kwargs["pk"])
162 
163         response = HttpResponse(content_type="text/csv")
164         response["Content-Disposition"] = 'attachment;filename="batch-topic.csv"'
165         writer = csv.writer(response)
166         headers = [
167             _("Topic"),
168             _("No. of payments"),
169             _("First payment"),
170             _("Last payment"),
171             _("Total amount"),
172         ]
173         writer.writerow([capfirst(x) for x in headers])
174 
175         topic_rows = (
176             batch.payments_set.values("topic")
177             .annotate(
178                 total=Sum("amount"),
179                 count=Count("paid_by"),
180                 min_date=Min("created_at"),
181                 max_date=Max("created_at"),
182             )
183             .order_by("topic")
184         )
185 
186         for row in topic_rows:
187             writer.writerow(
188                 [
189                     row["topic"],
190                     row["count"],
191                     timezone.localtime(row["min_date"]).date(),
192                     timezone.localtime(row["max_date"]).date(),
193                     f"{row['total']:.2f}",
194                 ]
195             )
196         return response
197 
198 
199 @method_decorator(staff_member_required, name="dispatch")
200 @method_decorator(
201     permission_required("payments.process_batches"), name="dispatch",
202 )
203 class BatchNewFilledAdminView(View):
204     """
205     View that adds a new batch filled with all payments that where not already in a batch.
206     """
207 
208     def get(self, request, *args, **kwargs):
209         batch = Batch()
210         batch.save()
211 
212         payments = Payment.objects.filter(type=Payment.TPAY, batch=None,)
213 
214         payments.update(batch=batch)
215 
216         return redirect("admin:payments_batch_change", object_id=batch.id)
217
```

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

{"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@@ -94,6 +94,9 @@\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)),\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@@ -3,11 +3,13 @@\n from typing import Union\n \n from django.conf import settings\n-from django.db.models import QuerySet, Q\n-from django.utils import timezone\n+from django.db.models import QuerySet, Q, Sum\n+from django.urls import reverse\n+from django.utils import timezone, translation\n from django.utils.translation import gettext_lazy as _\n \n from members.models import Member\n+from registrations.emails import _send_email\n from .exceptions import PaymentError\n from .models import Payment, BankAccount, Payable\n \n@@ -91,3 +93,32 @@\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+\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+    for member_row in member_payments:\n+        member = Member.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", "issue": "Send email with Thalia Pay payments that will be withdrawn on batch processed\n### Describe the solution you'd like\r\nWhen a payment batch is processed, all users that are in that batch should receive an email notifying them that we will withdraw some amount from their bank account.\r\n\r\n### Motivation\r\nIt is required for the SEPA direct debit mandate\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\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 members.models import Member\nfrom payments import services\nfrom .models import Payment, Batch\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, 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            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 = Member.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 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\nfrom django.utils import timezone\nfrom django.utils.translation import gettext_lazy as _\n\nfrom members.models import Member\nfrom .exceptions import PaymentError\nfrom .models import Payment, BankAccount, Payable\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: Member that processed this payment\n    :param pay_type: Payment type\n    :return: Payment object\n    \"\"\"\n    if pay_type == Payment.TPAY and not payable.payment_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 = payable.payment_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=payable.payment_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\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", "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\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 members.models import Member\nfrom payments import services\nfrom .models import Payment, Batch\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, 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 = Member.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 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\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: Member that processed this payment\n    :param pay_type: Payment type\n    :return: Payment object\n    \"\"\"\n    if pay_type == Payment.TPAY and not payable.payment_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 = payable.payment_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=payable.payment_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\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 = Member.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"}]}