From 727cc4b17166a35aa90e431b67dffe12ca94b200 Mon Sep 17 00:00:00 2001
From: Ties Dirksen
Date: Wed, 27 Nov 2024 21:04:11 +0100
Subject: [PATCH 1/8] Add reimbursement model
Co-authored-by: Mark Boute
Co-authored-by: Marijn Meuleman
---
website/reimbursements/__init__.py | 0
website/reimbursements/migrations/__init__.py | 0
website/reimbursements/models.py | 88 +++++++++++++++++++
3 files changed, 88 insertions(+)
create mode 100644 website/reimbursements/__init__.py
create mode 100644 website/reimbursements/migrations/__init__.py
create mode 100644 website/reimbursements/models.py
diff --git a/website/reimbursements/__init__.py b/website/reimbursements/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/website/reimbursements/migrations/__init__.py b/website/reimbursements/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/website/reimbursements/models.py b/website/reimbursements/models.py
new file mode 100644
index 000000000..2c2888e16
--- /dev/null
+++ b/website/reimbursements/models.py
@@ -0,0 +1,88 @@
+from django.core.exceptions import ValidationError
+from django.core.validators import MinLengthValidator
+from django.db import models
+
+from payments.models import PaymentAmountField
+
+
+class Reimbursement(models.Model):
+ class Verdict(models.TextChoices):
+ APPROVED = "approved", "Approved"
+ DENIED = "denied", "Denied"
+
+ owner = models.ForeignKey(
+ "auth.User",
+ related_name="reimbursements",
+ on_delete=models.PROTECT,
+ )
+
+ amount = PaymentAmountField(
+ max_digits=5,
+ decimal_places=2,
+ help_text="How much did you pay (in euros)?",
+ )
+
+ date_incurred = models.DateField(
+ help_text="When was this payment made?",
+ )
+
+ description = models.TextField(
+ max_length=512,
+ help_text="Why did you make this payment?",
+ validators=[MinLengthValidator(10)],
+ )
+
+ # explicitely chose for FileField over an ImageField because companies often send invoices as pdf
+ # TODO: verify file location
+ receipt = models.FileField(upload_to="receipts/")
+
+ created = models.DateTimeField(auto_now_add=True)
+
+ verdict = models.CharField(
+ max_length=40,
+ choices=Verdict.choices,
+ null=True,
+ blank=True,
+ )
+ verdict_clarification = models.TextField(
+ help_text="Why did you choose this verdict?",
+ null=True,
+ blank=True,
+ )
+
+ evaluated_at = models.DateTimeField(null=True)
+ evaluated_by = models.ForeignKey(
+ "auth.User",
+ related_name="reimbursements_approved",
+ on_delete=models.SET_NULL,
+ editable=False,
+ null=True,
+ )
+
+ class Meta:
+ ordering = ["created"]
+
+ def clean(self):
+ super().clean()
+
+ errors = {}
+ if self.date_incurred > self.created.date():
+ errors["date_incurred"] = "The date incurred cannot be in the future."
+
+ if self.verdict == self.Verdict.DENIED and not self.verdict_clarification:
+ errors["verdict_clarification"] = (
+ "You must provide a reason for the denial."
+ )
+
+ if (
+ self.verdict == self.Verdict.APPROVED
+ or self.verdict == self.Verdict.DENIED
+ and not self.evaluated_by
+ ):
+ errors["evaluated_by"] = "You must provide the evaluator."
+
+ if errors:
+ raise ValidationError(errors)
+
+ def __str__(self):
+ return f"Reimbursement #{self.id}"
From 0b70e8f8e54061cf5975254dab83f50562813e6a Mon Sep 17 00:00:00 2001
From: Ties Dirksen
Date: Wed, 27 Nov 2024 21:04:30 +0100
Subject: [PATCH 2/8] Add todo for services
---
website/reimbursements/services.py | 2 ++
1 file changed, 2 insertions(+)
create mode 100644 website/reimbursements/services.py
diff --git a/website/reimbursements/services.py b/website/reimbursements/services.py
new file mode 100644
index 000000000..f3bff489a
--- /dev/null
+++ b/website/reimbursements/services.py
@@ -0,0 +1,2 @@
+# TODO: remove DECLINED reimburstments after two years since verdict_date
+# remove APPROVED reimburstments after seven years since the verdict_date
From 69ea7e0ca19e591b74be65cda662fa66859934c3 Mon Sep 17 00:00:00 2001
From: Ties Dirksen
Date: Wed, 27 Nov 2024 21:04:43 +0100
Subject: [PATCH 3/8] Add start for apps
---
website/reimbursements/apps.py | 44 ++++++++++++++++++++++++++++++++++
1 file changed, 44 insertions(+)
create mode 100644 website/reimbursements/apps.py
diff --git a/website/reimbursements/apps.py b/website/reimbursements/apps.py
new file mode 100644
index 000000000..1e01bf5e9
--- /dev/null
+++ b/website/reimbursements/apps.py
@@ -0,0 +1,44 @@
+from django.contrib import admin
+from django.contrib.admin import register
+from django.utils import timezone
+
+from reimbursements import models
+
+
+@register(models.Reimbursement)
+class ReimbursementAdmin(admin.ModelAdmin):
+ list_display = (
+ "id",
+ "created",
+ "owner",
+ "date_incurred",
+ "amount",
+ "verdict",
+ )
+ list_filter = ("approved", "created", "date_incurred", "owner")
+
+ autocomplete_fields = ["owner"]
+ readonly_fields = ["approved_by", "approved_at", "created", "updated"]
+
+ def save_model(self, request, obj, form, change):
+ if obj.verdict is not None and obj.verdict != form.initial["verdict"]:
+ obj.evaluated_by = request.user
+ obj.evaluated_at = timezone.now()
+
+ super().save_model(request, obj, form, change)
+
+ if obj.verdict is not None and obj.verdict != form.initial["verdict"]:
+ # TODO: Send verdict conclusion e-mail to requester
+ pass
+
+ def get_readonly_fields(self, request, obj=None):
+ if obj and obj.approved:
+ return self.readonly_fields + [
+ "approved",
+ "amount",
+ "description",
+ "receipt",
+ "date_incurred",
+ "owner",
+ ]
+ return self.readonly_fields
From 6ac7b5b3d18995025b14be227ff0cb5ff5ce0acb Mon Sep 17 00:00:00 2001
From: Ties Dirksen
Date: Wed, 27 Nov 2024 21:04:55 +0100
Subject: [PATCH 4/8] Add start for views
---
website/reimbursements/views.py | 32 ++++++++++++++++++++++++++++++++
1 file changed, 32 insertions(+)
create mode 100644 website/reimbursements/views.py
diff --git a/website/reimbursements/views.py b/website/reimbursements/views.py
new file mode 100644
index 000000000..eb77c669e
--- /dev/null
+++ b/website/reimbursements/views.py
@@ -0,0 +1,32 @@
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.urls import reverse_lazy
+from django.views.generic import CreateView, ListView
+
+from reimbursements.models import Reimbursement
+
+
+class IndexView(LoginRequiredMixin, ListView):
+ model = Reimbursement
+ template_name = "reimbursements/index.html"
+
+ def get_queryset(self):
+ return super().get_queryset().filter(owner=self.request.user)
+
+
+class CreateReimbursementView(CreateView):
+ model = Reimbursement
+ template_name = "reimbursements/create.html"
+
+ fields = ["date_incurred", "amount", "description", "receipt"]
+
+ success_url = reverse_lazy("reimbursements:index")
+
+ def form_valid(self, form):
+ form.instance.owner = self.request.user
+ form.save()
+ return super().form_valid(form)
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data()
+ context["form"].fields["date_incurred"].widget.input_type = "date"
+ return context
From f2b629b32f98c47ebf487ace9939030b16bae523 Mon Sep 17 00:00:00 2001
From: Ties Dirksen
Date: Wed, 27 Nov 2024 21:05:31 +0100
Subject: [PATCH 5/8] Add some other stuff
---
website/reimbursements/urls.py | 10 ++++++++++
1 file changed, 10 insertions(+)
create mode 100644 website/reimbursements/urls.py
diff --git a/website/reimbursements/urls.py b/website/reimbursements/urls.py
new file mode 100644
index 000000000..e7609347e
--- /dev/null
+++ b/website/reimbursements/urls.py
@@ -0,0 +1,10 @@
+from django.urls import path
+
+from reimbursements.views import CreateReimbursementView, IndexView
+
+app_name = "reimbursements"
+
+urlpatterns = [
+ path("", IndexView.as_view(), name="index"),
+ path("create/", CreateReimbursementView.as_view(), name="create"),
+]
From a28bee9cd9d6a2c0ffdbb6f993402ae887a14a97 Mon Sep 17 00:00:00 2001
From: Ties Dirksen
Date: Wed, 27 Nov 2024 22:03:06 +0100
Subject: [PATCH 6/8] Add some stuff
---
website/reimbursements/admin.py | 73 +++++++++++++++++++++++
website/reimbursements/apps.py | 44 --------------
website/reimbursements/email/verdict.html | 19 ++++++
website/reimbursements/email/verdict.txt | 20 +++++++
website/reimbursements/models.py | 3 +-
website/reimbursements/services.py | 27 +++++++++
6 files changed, 141 insertions(+), 45 deletions(-)
create mode 100644 website/reimbursements/admin.py
create mode 100644 website/reimbursements/email/verdict.html
create mode 100644 website/reimbursements/email/verdict.txt
diff --git a/website/reimbursements/admin.py b/website/reimbursements/admin.py
new file mode 100644
index 000000000..32532456f
--- /dev/null
+++ b/website/reimbursements/admin.py
@@ -0,0 +1,73 @@
+from django.contrib import admin
+from django.contrib.admin import register
+from django.utils import timezone
+
+from reimbursements import models
+from utils.snippets import send_email
+
+
+@register(models.Reimbursement)
+class ReimbursementAdmin(admin.ModelAdmin):
+ list_display = (
+ "id",
+ "created",
+ "owner",
+ "date_incurred",
+ "amount",
+ "verdict",
+ "evaluated_by",
+ )
+ list_filter = ("verdict", "created", "date_incurred", "owner")
+
+ autocomplete_fields = ["owner"]
+ readonly_fields = [
+ "evaluated_by",
+ "evaluated_at",
+ "created",
+ "amount",
+ "receipt",
+ "owner",
+ "date_incurred",
+ ]
+
+ def send_verdict_email(self, obj):
+ send_email(
+ to=[obj.owner.email],
+ subject="Reimbursement request",
+ txt_template="reimbursements/email/verdict.txt",
+ html_template="reimbursements/email/verdict.html",
+ context={
+ "first_name": obj.owner.profile.firstname,
+ "description": obj.description,
+ "verdict": obj.verdict,
+ "verdict_clarification": obj.verdict_clarification,
+ },
+ )
+
+ def save_model(self, request, obj, form, change):
+ if obj.verdict is not None and obj.verdict != form.initial["verdict"]:
+ obj.evaluated_by = request.user
+ obj.evaluated_at = timezone.now()
+
+ super().save_model(request, obj, form, change)
+
+ if obj.verdict is not None and obj.verdict != form.initial["verdict"]:
+ self.send_verdict_email(obj)
+
+ def get_readonly_fields(self, obj=None):
+ if not obj or obj.verdict:
+ return self.readonly_fields + [
+ "verdict",
+ "description",
+ ]
+ return self.readonly_fields
+
+ def get_queryset(self, request):
+ if request.user.has_perm("reimbursements.view_reimbursement"):
+ return super().get_queryset(request)
+ return models.Reimbursement.objects.filter(owner=request.user)
+
+ def has_view_permission(self, request, obj=None) -> bool:
+ if obj and request.member and obj.owner == request.member:
+ return True
+ return super().has_view_permission(request, obj)
diff --git a/website/reimbursements/apps.py b/website/reimbursements/apps.py
index 1e01bf5e9..e69de29bb 100644
--- a/website/reimbursements/apps.py
+++ b/website/reimbursements/apps.py
@@ -1,44 +0,0 @@
-from django.contrib import admin
-from django.contrib.admin import register
-from django.utils import timezone
-
-from reimbursements import models
-
-
-@register(models.Reimbursement)
-class ReimbursementAdmin(admin.ModelAdmin):
- list_display = (
- "id",
- "created",
- "owner",
- "date_incurred",
- "amount",
- "verdict",
- )
- list_filter = ("approved", "created", "date_incurred", "owner")
-
- autocomplete_fields = ["owner"]
- readonly_fields = ["approved_by", "approved_at", "created", "updated"]
-
- def save_model(self, request, obj, form, change):
- if obj.verdict is not None and obj.verdict != form.initial["verdict"]:
- obj.evaluated_by = request.user
- obj.evaluated_at = timezone.now()
-
- super().save_model(request, obj, form, change)
-
- if obj.verdict is not None and obj.verdict != form.initial["verdict"]:
- # TODO: Send verdict conclusion e-mail to requester
- pass
-
- def get_readonly_fields(self, request, obj=None):
- if obj and obj.approved:
- return self.readonly_fields + [
- "approved",
- "amount",
- "description",
- "receipt",
- "date_incurred",
- "owner",
- ]
- return self.readonly_fields
diff --git a/website/reimbursements/email/verdict.html b/website/reimbursements/email/verdict.html
new file mode 100644
index 000000000..e5a93a487
--- /dev/null
+++ b/website/reimbursements/email/verdict.html
@@ -0,0 +1,19 @@
+{% extends "email/html_email.html" %}
+
+{% block content %}
+ Dear {{ first_name }},
+
+
+ There has been a verdict on your reinbursement request with the following description:
+
{{ description }}
+
+ The request was {{ verdict }}.
+ {% if not verdict_clarification %}
+ No clarification was given.
+ {% else %}
+ The following clarification was given:
+
{{ verdict_clarification }}
+ {% endif %}
+
+ For questions, please reach out to the treasurer at treasurer@thalia.nu.
+{% endblock %}
diff --git a/website/reimbursements/email/verdict.txt b/website/reimbursements/email/verdict.txt
new file mode 100644
index 000000000..70fc55907
--- /dev/null
+++ b/website/reimbursements/email/verdict.txt
@@ -0,0 +1,20 @@
+Dear {{ first_name }},
+
+
+There has been a verdict on your reinbursement request with the following description:
+ {{ description }}
+
+The request was {{ verdict }}.
+{% if not verdict_clarification %}No clarification was given.
+{% else %}The following clarification was given:
+ {{ verdict_clarification }}{% endif %}
+
+For questions, please reach out to the treasurer at treasurer@thalia.nu.
+
+
+With kind regards,
+
+The board of Study Association thalia
+
+
+This email was automatically generated.
diff --git a/website/reimbursements/models.py b/website/reimbursements/models.py
index 2c2888e16..09a999614 100644
--- a/website/reimbursements/models.py
+++ b/website/reimbursements/models.py
@@ -2,6 +2,7 @@
from django.core.validators import MinLengthValidator
from django.db import models
+from members.models import Member
from payments.models import PaymentAmountField
@@ -11,7 +12,7 @@ class Verdict(models.TextChoices):
DENIED = "denied", "Denied"
owner = models.ForeignKey(
- "auth.User",
+ model=Member,
related_name="reimbursements",
on_delete=models.PROTECT,
)
diff --git a/website/reimbursements/services.py b/website/reimbursements/services.py
index f3bff489a..241332bb0 100644
--- a/website/reimbursements/services.py
+++ b/website/reimbursements/services.py
@@ -1,2 +1,29 @@
+from datetime import timedelta
+
+from django.utils import timezone
+
+from .models import Reimbursement
+
# TODO: remove DECLINED reimburstments after two years since verdict_date
# remove APPROVED reimburstments after seven years since the verdict_date
+
+
+YEAR = timedelta(days=365)
+
+
+def execute_data_minimisation(dry_run=False):
+ declined_reimburstments = Reimbursement.objects.filter(
+ verdict=Reimbursement.Verdict.DENIED
+ )
+
+ for reimbursement in declined_reimburstments:
+ if reimbursement.created.date() + YEAR * 2 < timezone.now().date():
+ reimbursement.delete()
+
+ approved_reimbursements = Reimbursement.objects.filter(
+ verdict=Reimbursement.Verdict.APPROVED
+ )
+
+ for reimbursement in approved_reimbursements:
+ if reimbursement.created.date() + YEAR * 7 < timezone.now().date():
+ reimbursement.delete()
From c89ad4668e3b47acd120729e2516271222e16ef9 Mon Sep 17 00:00:00 2001
From: Ties Dirksen
Date: Wed, 4 Dec 2024 22:50:28 +0100
Subject: [PATCH 7/8] Add a lot of stuffs
Co-authored-by: Marijn Meuleman
Co-authored-by: Dirk Doesburg
---
pyproject.toml | 1 +
website/moneybirdsynchronization/moneybird.py | 21 ++++-
website/moneybirdsynchronization/services.py | 4 +-
.../tests/test_services.py | 6 +-
website/reimbursements/admin.py | 41 ++++++----
website/reimbursements/apps.py | 9 +++
.../reimbursements/migrations/0001_initial.py | 39 ++++++++++
website/reimbursements/models.py | 30 +++++---
website/reimbursements/services.py | 32 ++++----
website/reimbursements/tests/__init__.py | 0
website/reimbursements/tests/test_admin.py | 65 ++++++++++++++++
website/reimbursements/tests/test_models.py | 77 +++++++++++++++++++
website/reimbursements/urls.py | 10 ---
website/reimbursements/views.py | 32 --------
website/thaliawebsite/settings.py | 1 +
website/thaliawebsite/tasks.py | 8 ++
16 files changed, 289 insertions(+), 87 deletions(-)
create mode 100644 website/reimbursements/migrations/0001_initial.py
create mode 100644 website/reimbursements/tests/__init__.py
create mode 100644 website/reimbursements/tests/test_admin.py
create mode 100644 website/reimbursements/tests/test_models.py
delete mode 100644 website/reimbursements/urls.py
delete mode 100644 website/reimbursements/views.py
diff --git a/pyproject.toml b/pyproject.toml
index 05af81105..61afcb038 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -125,6 +125,7 @@ known-first-party = [
"promotion",
"pushnotifications",
"registrations",
+ "reimbursements",
"sales",
"shortlinks",
"singlepages",
diff --git a/website/moneybirdsynchronization/moneybird.py b/website/moneybirdsynchronization/moneybird.py
index 714ab6781..244bd6bd6 100644
--- a/website/moneybirdsynchronization/moneybird.py
+++ b/website/moneybirdsynchronization/moneybird.py
@@ -29,7 +29,7 @@ def update_external_sales_invoice(self, invoice_id, invoice_data):
f"external_sales_invoices/{invoice_id}", invoice_data
)
- def delete_external_invoice(self, invoice_id):
+ def delete_external_sales_invoice(self, invoice_id):
self._administration.delete(f"external_sales_invoices/{invoice_id}")
def register_external_invoice_payment(self, invoice_id, payment_data):
@@ -53,6 +53,25 @@ def update_financial_statement(self, statement_id, statement_data):
def delete_financial_statement(self, statement_id):
return self._administration.delete(f"financial_statements/{statement_id}")
+ def create_receipt(self, receipt_data):
+ return self._administration.post("receipts", receipt_data)
+
+ def delete_receipt(self, receipt_id):
+ return self._administration.delete(f"receipts/{receipt_id}")
+
+ def update_receipt(self, receipt_id, receipt_data):
+ return self._administration.patch(f"receipts/{receipt_id}", receipt_data)
+
+ def add_receipt_attachment(self, receipt_id, receipt_attachment):
+ return self._administration.post(
+ f"receipts/{receipt_id}/attachments", receipt_attachment
+ )
+
+ def delete_receipt_attachment(self, receipt_id, attachment_id):
+ return self._administration.delete(
+ f"receipts/{receipt_id}/attachments/{attachment_id}"
+ )
+
def link_mutation_to_booking(
self,
mutation_id: int,
diff --git a/website/moneybirdsynchronization/services.py b/website/moneybirdsynchronization/services.py
index 6c245c30d..d030d27b8 100644
--- a/website/moneybirdsynchronization/services.py
+++ b/website/moneybirdsynchronization/services.py
@@ -185,7 +185,7 @@ def delete_external_invoice(obj):
moneybird = get_moneybird_api_service()
try:
- moneybird.delete_external_invoice(external_invoice.moneybird_invoice_id)
+ moneybird.delete_external_sales_invoice(external_invoice.moneybird_invoice_id)
except Administration.NotFound:
# The invoice has probably been removed manually from moneybird.
# We can assume it no longer exists there, but still, this should not happen
@@ -238,7 +238,7 @@ def _delete_invoices():
for invoice in invoices:
try:
if invoice.moneybird_invoice_id is not None:
- moneybird.delete_external_invoice(invoice.moneybird_invoice_id)
+ moneybird.delete_external_sales_invoice(invoice.moneybird_invoice_id)
invoice.delete()
except Administration.Error as e:
logger.exception("Moneybird synchronization error: %s", e)
diff --git a/website/moneybirdsynchronization/tests/test_services.py b/website/moneybirdsynchronization/tests/test_services.py
index f861aa88f..8a6089d19 100644
--- a/website/moneybirdsynchronization/tests/test_services.py
+++ b/website/moneybirdsynchronization/tests/test_services.py
@@ -225,8 +225,8 @@ def test_delete_invoices(self, mock_api):
moneybird_invoice_id="2",
)
- # _delete_invoices calls the delete_external_invoice API directly.
- mock_delete_invoice = mock_api.return_value.delete_external_invoice
+ # _delete_invoices calls the delete_external_sales_invoice API directly.
+ mock_delete_invoice = mock_api.return_value.delete_external_sales_invoice
with self.subTest("Invoices without needs_deletion are not deleted."):
services._delete_invoices()
@@ -458,7 +458,7 @@ def test_sync_renewals(self, mock_create_invoice, mock_api):
# Renewal 2 is not paid yet, so no invoice should be made for it.
mock_create_invoice.assert_called_once_with(renewal1)
- @mock.patch("moneybirdsynchronization.services.delete_external_invoice")
+ @mock.patch("moneybirdsynchronization.services.delete_external_sales_invoice")
@mock.patch("moneybirdsynchronization.services.create_or_update_external_invoice")
def test_sync_event_registrations(
self, mock_create_invoice, mock_delete_invoice, mock_api
diff --git a/website/reimbursements/admin.py b/website/reimbursements/admin.py
index 32532456f..fe754c166 100644
--- a/website/reimbursements/admin.py
+++ b/website/reimbursements/admin.py
@@ -1,13 +1,13 @@
from django.contrib import admin
-from django.contrib.admin import register
from django.utils import timezone
-from reimbursements import models
from utils.snippets import send_email
+from . import models
-@register(models.Reimbursement)
-class ReimbursementAdmin(admin.ModelAdmin):
+
+@admin.register(models.Reimbursement)
+class ReimbursementsAdmin(admin.ModelAdmin):
list_display = (
"id",
"created",
@@ -17,17 +17,19 @@ class ReimbursementAdmin(admin.ModelAdmin):
"verdict",
"evaluated_by",
)
- list_filter = ("verdict", "created", "date_incurred", "owner")
+ list_filter = ("verdict", "created", "date_incurred")
+ search_fields = (
+ "date_incurred",
+ "owner__first_name",
+ "owner__last_name",
+ "description",
+ )
- autocomplete_fields = ["owner"]
readonly_fields = [
"evaluated_by",
"evaluated_at",
"created",
- "amount",
- "receipt",
"owner",
- "date_incurred",
]
def send_verdict_email(self, obj):
@@ -45,25 +47,36 @@ def send_verdict_email(self, obj):
)
def save_model(self, request, obj, form, change):
+ obj.owner = request.user
if obj.verdict is not None and obj.verdict != form.initial["verdict"]:
obj.evaluated_by = request.user
obj.evaluated_at = timezone.now()
+ # TODO: add moneybird integration
+
super().save_model(request, obj, form, change)
if obj.verdict is not None and obj.verdict != form.initial["verdict"]:
self.send_verdict_email(obj)
- def get_readonly_fields(self, obj=None):
+ def get_readonly_fields(self, request, obj=None):
+ readonly = self.readonly_fields
+ if obj:
+ readonly += [
+ "description",
+ "amount",
+ "date_incurred",
+ "receipt",
+ ]
if not obj or obj.verdict:
- return self.readonly_fields + [
+ readonly += [
"verdict",
- "description",
+ "verdict_clarification",
]
- return self.readonly_fields
+ return readonly
def get_queryset(self, request):
- if request.user.has_perm("reimbursements.view_reimbursement"):
+ if request.user.has_perm("reimbursements.change_reimbursement"):
return super().get_queryset(request)
return models.Reimbursement.objects.filter(owner=request.user)
diff --git a/website/reimbursements/apps.py b/website/reimbursements/apps.py
index e69de29bb..3a3c31c37 100644
--- a/website/reimbursements/apps.py
+++ b/website/reimbursements/apps.py
@@ -0,0 +1,9 @@
+from django.apps import AppConfig
+from django.utils.translation import gettext_lazy as _
+
+
+class ReimbursementsConfig(AppConfig):
+ """AppConfig for the announcement package."""
+
+ name = "reimbursements"
+ verbose_name = _("Site reimbursements")
diff --git a/website/reimbursements/migrations/0001_initial.py b/website/reimbursements/migrations/0001_initial.py
new file mode 100644
index 000000000..838bf008e
--- /dev/null
+++ b/website/reimbursements/migrations/0001_initial.py
@@ -0,0 +1,39 @@
+# Generated by Django 5.1.2 on 2024-12-04 18:57
+
+import django.core.validators
+import django.db.models.deletion
+import payments.models
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('members', '0051_membership_study_long_until'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Reimbursement',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('amount', payments.models.PaymentAmountField(decimal_places=2, help_text='How much did you pay (in euros)?', max_digits=8, validators=[payments.models.validate_not_zero])),
+ ('date_incurred', models.DateField(help_text='When was this payment made?')),
+ ('description', models.TextField(help_text='Why did you make this payment?', max_length=512, validators=[django.core.validators.MinLengthValidator(10)])),
+ ('receipt', models.FileField(upload_to='receipts/')),
+ ('created', models.DateTimeField(auto_now_add=True)),
+ ('verdict', models.CharField(blank=True, choices=[('approved', 'Approved'), ('denied', 'Denied')], max_length=40, null=True)),
+ ('verdict_clarification', models.TextField(blank=True, help_text='Why did you choose this verdict?', null=True)),
+ ('evaluated_at', models.DateTimeField(null=True)),
+ ('evaluated_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reimbursements_approved', to=settings.AUTH_USER_MODEL)),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reimbursements', to='members.member')),
+ ],
+ options={
+ 'ordering': ['created'],
+ },
+ ),
+ ]
diff --git a/website/reimbursements/models.py b/website/reimbursements/models.py
index 09a999614..461ab170d 100644
--- a/website/reimbursements/models.py
+++ b/website/reimbursements/models.py
@@ -2,8 +2,8 @@
from django.core.validators import MinLengthValidator
from django.db import models
-from members.models import Member
-from payments.models import PaymentAmountField
+from payments.models import BankAccount, PaymentAmountField
+from utils.media.services import get_upload_to_function
class Reimbursement(models.Model):
@@ -12,7 +12,7 @@ class Verdict(models.TextChoices):
DENIED = "denied", "Denied"
owner = models.ForeignKey(
- model=Member,
+ "members.Member",
related_name="reimbursements",
on_delete=models.PROTECT,
)
@@ -34,8 +34,10 @@ class Verdict(models.TextChoices):
)
# explicitely chose for FileField over an ImageField because companies often send invoices as pdf
- # TODO: verify file location
- receipt = models.FileField(upload_to="receipts/")
+
+ receipt = models.FileField(
+ upload_to=get_upload_to_function("reimbursements/receipts"),
+ )
created = models.DateTimeField(auto_now_add=True)
@@ -65,9 +67,17 @@ class Meta:
def clean(self):
super().clean()
-
+ bank = BankAccount.objects.filter(owner=self.owner).last()
errors = {}
- if self.date_incurred > self.created.date():
+ if bank is None:
+ errors["owner"] = (
+ "You must have a valid bank account to request a reimbursement."
+ )
+ if (
+ self.created is not None
+ and self.date_incurred is not None
+ and self.date_incurred > self.created.date()
+ ):
errors["date_incurred"] = "The date incurred cannot be in the future."
if self.verdict == self.Verdict.DENIED and not self.verdict_clarification:
@@ -76,10 +86,8 @@ def clean(self):
)
if (
- self.verdict == self.Verdict.APPROVED
- or self.verdict == self.Verdict.DENIED
- and not self.evaluated_by
- ):
+ self.verdict == self.Verdict.APPROVED or self.verdict == self.Verdict.DENIED
+ ) and not self.evaluated_by:
errors["evaluated_by"] = "You must provide the evaluator."
if errors:
diff --git a/website/reimbursements/services.py b/website/reimbursements/services.py
index 241332bb0..a8edfd30d 100644
--- a/website/reimbursements/services.py
+++ b/website/reimbursements/services.py
@@ -1,29 +1,33 @@
+import logging
from datetime import timedelta
from django.utils import timezone
from .models import Reimbursement
-# TODO: remove DECLINED reimburstments after two years since verdict_date
-# remove APPROVED reimburstments after seven years since the verdict_date
-
-
+logger = logging.getLogger(__name__)
YEAR = timedelta(days=365)
def execute_data_minimisation(dry_run=False):
- declined_reimburstments = Reimbursement.objects.filter(
- verdict=Reimbursement.Verdict.DENIED
+ old_declined_reimbursements = Reimbursement.objects.filter(
+ verdict=Reimbursement.Verdict.DENIED,
+ created__lt=timezone.now() - YEAR * 2,
)
- for reimbursement in declined_reimburstments:
- if reimbursement.created.date() + YEAR * 2 < timezone.now().date():
- reimbursement.delete()
+ logger.info(
+ "Deleting %d declined reimbursements", old_declined_reimbursements.count()
+ )
+ if not dry_run:
+ old_declined_reimbursements.delete()
- approved_reimbursements = Reimbursement.objects.filter(
- verdict=Reimbursement.Verdict.APPROVED
+ old_approved_reimbursements = Reimbursement.objects.filter(
+ verdict=Reimbursement.Verdict.APPROVED,
+ created__lt=timezone.now() - YEAR * 7,
)
- for reimbursement in approved_reimbursements:
- if reimbursement.created.date() + YEAR * 7 < timezone.now().date():
- reimbursement.delete()
+ logger.info(
+ "Deleting %d approved reimbursements", old_approved_reimbursements.count()
+ )
+ if not dry_run:
+ old_approved_reimbursements.delete()
diff --git a/website/reimbursements/tests/__init__.py b/website/reimbursements/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/website/reimbursements/tests/test_admin.py b/website/reimbursements/tests/test_admin.py
new file mode 100644
index 000000000..2a4627830
--- /dev/null
+++ b/website/reimbursements/tests/test_admin.py
@@ -0,0 +1,65 @@
+from django.contrib.admin.sites import AdminSite
+from django.test import TestCase
+from django.utils import timezone
+
+from members.models import Member
+from reimbursements.admin import ReimbursementsAdmin
+from reimbursements.models import Reimbursement
+
+
+class MockRequest:
+ def __init__(self, user):
+ self.user = user
+
+
+class ReimbursementsAdminTests(TestCase):
+ def setUp(self):
+ self.site = AdminSite()
+ self.user = Member.objects.create_user(username="testuser", password="12345")
+ self.superuser = Member.objects.create_superuser(
+ username="admin", password="12345", email="admin@example.com"
+ )
+ self.reimbursement = Reimbursement.objects.create(
+ owner=self.user,
+ description="Test reimbursement",
+ amount=100,
+ date_incurred=timezone.now(),
+ )
+ self.admin = ReimbursementsAdmin(model=Reimbursement, admin_site=self.site)
+
+ def test_get_queryset_for_superuser(self):
+ request = MockRequest(self.superuser)
+ queryset = self.admin.get_queryset(request)
+ self.assertIn(self.reimbursement, queryset)
+
+ def test_get_queryset_for_normal_user(self):
+ request = MockRequest(self.user)
+ queryset = self.admin.get_queryset(request)
+ self.assertIn(self.reimbursement, queryset)
+
+ def test_save_model_sets_owner(self):
+ request = MockRequest(self.superuser)
+ reimbursement = Reimbursement(
+ description="Another test", amount=200, date_incurred=timezone.now()
+ )
+ self.admin.save_model(request, reimbursement, None, False)
+ self.assertEqual(reimbursement.owner, self.superuser)
+
+ def test_get_readonly_fields(self):
+ self.reimbursement.verdict = "approved"
+ self.reimbursement.verdict_clarification = "Approved by admin"
+ request = MockRequest(self.superuser)
+ readonly_fields = self.admin.get_readonly_fields(request, self.reimbursement)
+ self.assertIn("description", readonly_fields)
+ self.assertIn("amount", readonly_fields)
+ self.assertIn("date_incurred", readonly_fields)
+ self.assertIn("receipt", readonly_fields)
+ self.assertIn("verdict", readonly_fields)
+ self.assertIn("verdict_clarification", readonly_fields)
+
+ def test_has_view_permission(self):
+ request = MockRequest(self.user)
+ request.member = self.user
+ self.assertTrue(self.admin.has_view_permission(request, self.reimbursement))
+ request.user = self.superuser
+ self.assertTrue(self.admin.has_view_permission(request, self.reimbursement))
diff --git a/website/reimbursements/tests/test_models.py b/website/reimbursements/tests/test_models.py
new file mode 100644
index 000000000..7083a353c
--- /dev/null
+++ b/website/reimbursements/tests/test_models.py
@@ -0,0 +1,77 @@
+import datetime
+
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+from django.utils import timezone
+
+from members.models import Member
+
+from ..models import Reimbursement
+
+
+class ReimbursementModelTest(TestCase):
+ def setUp(self):
+ self.user = Member.objects.create_user(username="testuser", password="12345")
+
+ def test_future_date_incurred(self):
+ reimbursement = Reimbursement(
+ created=timezone.now(),
+ date_incurred=timezone.now().date() + datetime.timedelta(days=1),
+ )
+ with self.assertRaises(ValidationError) as context:
+ reimbursement.clean()
+ self.assertIn("date_incurred", context.exception.message_dict)
+ self.assertEqual(
+ context.exception.message_dict["date_incurred"],
+ ["The date incurred cannot be in the future."],
+ )
+
+ def test_denied_verdict_without_clarification(self):
+ reimbursement = Reimbursement(
+ created=timezone.now(),
+ verdict=Reimbursement.Verdict.DENIED,
+ )
+ with self.assertRaises(ValidationError) as context:
+ reimbursement.clean()
+ self.assertIn("verdict_clarification", context.exception.message_dict)
+ self.assertEqual(
+ context.exception.message_dict["verdict_clarification"],
+ ["You must provide a reason for the denial."],
+ )
+
+ def test_approved_verdict_without_evaluator(self):
+ reimbursement = Reimbursement(
+ created=timezone.now(),
+ verdict=Reimbursement.Verdict.APPROVED,
+ )
+ with self.assertRaises(ValidationError) as context:
+ reimbursement.clean()
+ self.assertIn("evaluated_by", context.exception.message_dict)
+ self.assertEqual(
+ context.exception.message_dict["evaluated_by"],
+ ["You must provide the evaluator."],
+ )
+
+ def test_denied_verdict_without_evaluator(self):
+ reimbursement = Reimbursement(
+ created=timezone.now(),
+ verdict=Reimbursement.Verdict.DENIED,
+ )
+ with self.assertRaises(ValidationError) as context:
+ reimbursement.clean()
+ self.assertIn("evaluated_by", context.exception.message_dict)
+ self.assertEqual(
+ context.exception.message_dict["evaluated_by"],
+ ["You must provide the evaluator."],
+ )
+
+ def test_valid_reimbursement(self):
+ reimbursement = Reimbursement(
+ created=timezone.now(),
+ date_incurred=timezone.now().date(),
+ verdict=Reimbursement.Verdict.APPROVED,
+ evaluated_by=self.user,
+ verdict_clarification="daslkfjlkdsajfkdsajfldsakfjldska",
+ )
+
+ reimbursement.clean()
diff --git a/website/reimbursements/urls.py b/website/reimbursements/urls.py
deleted file mode 100644
index e7609347e..000000000
--- a/website/reimbursements/urls.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from django.urls import path
-
-from reimbursements.views import CreateReimbursementView, IndexView
-
-app_name = "reimbursements"
-
-urlpatterns = [
- path("", IndexView.as_view(), name="index"),
- path("create/", CreateReimbursementView.as_view(), name="create"),
-]
diff --git a/website/reimbursements/views.py b/website/reimbursements/views.py
deleted file mode 100644
index eb77c669e..000000000
--- a/website/reimbursements/views.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from django.contrib.auth.mixins import LoginRequiredMixin
-from django.urls import reverse_lazy
-from django.views.generic import CreateView, ListView
-
-from reimbursements.models import Reimbursement
-
-
-class IndexView(LoginRequiredMixin, ListView):
- model = Reimbursement
- template_name = "reimbursements/index.html"
-
- def get_queryset(self):
- return super().get_queryset().filter(owner=self.request.user)
-
-
-class CreateReimbursementView(CreateView):
- model = Reimbursement
- template_name = "reimbursements/create.html"
-
- fields = ["date_incurred", "amount", "description", "receipt"]
-
- success_url = reverse_lazy("reimbursements:index")
-
- def form_valid(self, form):
- form.instance.owner = self.request.user
- form.save()
- return super().form_valid(form)
-
- def get_context_data(self, **kwargs):
- context = super().get_context_data()
- context["form"].fields["date_incurred"].widget.input_type = "date"
- return context
diff --git a/website/thaliawebsite/settings.py b/website/thaliawebsite/settings.py
index 979e14620..325b16329 100644
--- a/website/thaliawebsite/settings.py
+++ b/website/thaliawebsite/settings.py
@@ -638,6 +638,7 @@ def show_toolbar(request):
"shortlinks.apps.ShortLinkConfig",
"sales.apps.SalesConfig",
"moneybirdsynchronization.apps.MoneybirdsynchronizationConfig",
+ "reimbursements.apps.ReimbursementsConfig",
"two_factor.plugins.webauthn",
]
diff --git a/website/thaliawebsite/tasks.py b/website/thaliawebsite/tasks.py
index 41462f549..e3dca575e 100644
--- a/website/thaliawebsite/tasks.py
+++ b/website/thaliawebsite/tasks.py
@@ -11,6 +11,7 @@
from members import services as members_services
from payments import services as payments_services
from pizzas import services as pizzas_services
+from reimbursements import services as reimbursements_services
from sales import services as sales_services
from utils.snippets import minimise_logentries_data
@@ -48,6 +49,12 @@ def data_minimisation():
for p in processed:
logger.info(f"Removed reference faces: {p}")
+ processed = members_services.execute_data_minimisation()
+ for p in processed:
+ logger.info(f"Removed data for {p}")
+
+ reimbursements_services.execute_data_minimisation()
+
processed = minimise_logentries_data()
logger.info(f"Removed {processed} log entries")
@@ -61,3 +68,4 @@ def clean_up():
@shared_task
def clear_tokens():
clear_expired()
+ clear_expired()
From cb8ddc0955d33bb944b595a49fee5bdd5f5792b9 Mon Sep 17 00:00:00 2001
From: Ties Dirksen
Date: Wed, 11 Dec 2024 21:15:53 +0100
Subject: [PATCH 8/8] Co-authored-by: Mark Boute
Co-authored-by: Marijn Meuleman
Add some stuff
---
website/moneybirdsynchronization/models.py | 104 +++++++++++++++++++
website/moneybirdsynchronization/services.py | 79 +++++++++++++-
website/reimbursements/admin.py | 3 +-
website/reimbursements/models.py | 2 +-
website/reimbursements/services.py | 38 +++----
5 files changed, 200 insertions(+), 26 deletions(-)
diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py
index b4b3e2cd0..7b6cb7330 100644
--- a/website/moneybirdsynchronization/models.py
+++ b/website/moneybirdsynchronization/models.py
@@ -15,6 +15,7 @@
from payments.payables import payables
from pizzas.models import FoodOrder
from registrations.models import Registration, Renewal
+from reimbursements.models import Reimbursement
from sales.models.order import Order
@@ -346,6 +347,109 @@ class Meta:
unique_together = ("payable_model", "object_id")
+class MoneybirdReceipt(models.Model):
+ reimbursement = models.OneToOneField(Reimbursement, on_delete=models.CASCADE)
+ object_id = models.CharField(max_length=255)
+
+ moneybird_receipt_id = models.CharField(
+ verbose_name=_("moneybird receipt id"),
+ max_length=255,
+ blank=True,
+ null=True,
+ )
+
+ moneybird_details_attribute_id = models.CharField(
+ verbose_name=_("moneybird details attribute id"),
+ max_length=255,
+ blank=True,
+ null=True,
+ ) # We need this id, so we can update the rows (otherwise, updates will create new rows without deleting).
+ # We only support one attribute for now, so this is the easiest way to store it
+
+ needs_synchronization = models.BooleanField(
+ default=True, # The field is set False only when it has been successfully synchronized.
+ help_text="Indicates that the invoice has to be synchronized (again).",
+ )
+
+ needs_deletion = models.BooleanField(
+ default=False,
+ help_text="Indicates that the invoice has to be deleted from moneybird.",
+ )
+
+ def to_moneybird(self):
+ moneybird = get_moneybird_api_service()
+
+ contact_id = settings.MONEYBIRD_UNKNOWN_PAYER_CONTACT_ID
+
+ if self.reimbursement.owner is not None:
+ try:
+ moneybird_contact = MoneybirdContact.objects.get(
+ member=self.reimbursement.owner
+ )
+ contact_id = moneybird_contact.moneybird_id
+ except MoneybirdContact.DoesNotExist:
+ pass
+
+ receipt_date = self.reimbursement.date_incurred.strftime("%Y-%m-%d")
+
+ project_name = f"Reimbursement [{receipt_date}]"
+
+ project_id = None
+ if project_name is not None:
+ project, __ = MoneybirdProject.objects.get_or_create(name=project_name)
+ if project.moneybird_id is None:
+ response = moneybird.create_project(project.to_moneybird())
+ project.moneybird_id = response["id"]
+ project.save()
+
+ project_id = project.moneybird_id
+
+ source_url = settings.BASE_URL + reverse(
+ f"admin:{self.reimbursement._meta.app_label}_{self.reimbursement._meta.model_name}_change",
+ args=(self.object_id,),
+ )
+
+ data = {
+ "receipt": {
+ "contact_id": int(contact_id),
+ "reference": f"Receipt [{self.reimbursement.pk}]",
+ "source": f"Concrexit ({settings.SITE_DOMAIN})",
+ "date": receipt_date,
+ "currency": "EUR",
+ "prices_are_incl_tax": True,
+ "details_attributes": [
+ {
+ "description": self.reimbursement.description,
+ "price": str(self.reimbursement.amount),
+ },
+ ],
+ }
+ }
+
+ if source_url is not None:
+ data["external_sales_invoice"]["source_url"] = source_url
+
+ if project_id is not None:
+ data["external_sales_invoice"]["details_attributes"][0]["project_id"] = int(
+ project_id
+ )
+
+ if self.moneybird_details_attribute_id is not None:
+ data["external_sales_invoice"]["details_attributes"][0]["id"] = int(
+ self.moneybird_details_attribute_id
+ )
+
+ return data
+
+ def __str__(self):
+ return f"Moneybird external invoice for {self.payable_object}"
+
+ class Meta:
+ verbose_name = _("moneybird external invoice")
+ verbose_name_plural = _("moneybird external invoices")
+ unique_together = ("payable_model", "object_id")
+
+
class MoneybirdPayment(models.Model):
payment = models.OneToOneField(
"payments.Payment",
diff --git a/website/moneybirdsynchronization/services.py b/website/moneybirdsynchronization/services.py
index d030d27b8..f4c249d66 100644
--- a/website/moneybirdsynchronization/services.py
+++ b/website/moneybirdsynchronization/services.py
@@ -16,12 +16,14 @@
MoneybirdContact,
MoneybirdExternalInvoice,
MoneybirdPayment,
+ MoneybirdReceipt,
financial_account_id_for_payment_type,
)
from moneybirdsynchronization.moneybird import get_moneybird_api_service
from payments.models import BankAccount, Payment
from pizzas.models import FoodOrder
from registrations.models import Registration, Renewal
+from reimbursements.models import Reimbursement
from sales.models.order import Order
logger = logging.getLogger(__name__)
@@ -199,6 +201,64 @@ def delete_external_invoice(obj):
external_invoice.delete()
+def create_or_update_receipt(reimbursement: Reimbursement):
+ """Create a receipt on Moneybird for a Reimbursement object."""
+ if not settings.MONEYBIRD_SYNC_ENABLED:
+ return
+
+ if reimbursement.verdict != Reimbursement.verdicts.APPROVED:
+ return
+
+ moneybird_receipt, _ = MoneybirdReceipt.objects.get_or_create(
+ reimbursement=reimbursement
+ )
+
+ moneybird = get_moneybird_api_service()
+
+ if moneybird_receipt.moneybird_receipt_id:
+ moneybird.update_receipt(
+ moneybird_receipt.moneybird_receipt_id, moneybird_receipt.to_moneybird()
+ )
+ else:
+ response = moneybird.create_receipt(moneybird_receipt.to_moneybird())
+ moneybird_receipt.moneybird_receipt_id = response["id"]
+ moneybird_receipt.moneybird_details_attribute_id = response["details"][0]["id"]
+ attachment_response = moneybird.add_receipt_attachment(
+ moneybird_receipt.moneybird_receipt_id, reimbursement.receipt
+ )
+ moneybird_receipt.moneybird_receipt_attachment_id = attachment_response["id"]
+
+ moneybird_receipt.needs_synchronization = False
+ moneybird_receipt.save()
+
+ return moneybird_receipt
+
+
+def delete_receipt(receipt: MoneybirdReceipt):
+ """Delete or archive a receipt on Moneybird, and delete our record of it."""
+ if not settings.MONEYBIRD_SYNC_ENABLED:
+ return
+
+ if receipt.moneybird_receipt_id is None:
+ receipt.delete()
+ return
+
+ moneybird = get_moneybird_api_service()
+
+ try:
+ if receipt.moneybird_receipt_attachment_id:
+ moneybird.delete_receipt_attachment(receipt.moneybird_receipt_attachment_id)
+ moneybird.delete_receipt(receipt.moneybird_id)
+ receipt.delete()
+ except Administration.InvalidData:
+ logger.warning(
+ "Receipt %s for reimbursement %s could not be deleted.",
+ receipt.moneybird_receipt_id,
+ receipt.reimbursement,
+ )
+ receipt.delete()
+
+
def synchronize_moneybird():
"""Perform all synchronization to moneybird."""
if not settings.MONEYBIRD_SYNC_ENABLED:
@@ -213,18 +273,20 @@ def synchronize_moneybird():
# already exist on moneybird.
_sync_moneybird_payments()
- # Delete invoices that have been marked for deletion.
+ # Delete invoices and receipts that have been marked for deletion.
_delete_invoices()
+ _delete_receipts()
# Resynchronize outdated invoices.
_sync_outdated_invoices()
- # Push all invoices to moneybird.
+ # Push all invoices and receipts to moneybird.
_sync_food_orders()
_sync_sales_orders()
_sync_registrations()
_sync_renewals()
_sync_event_registrations()
+ # _sync_receipts()
logger.info("Finished moneybird synchronization.")
@@ -245,6 +307,15 @@ def _delete_invoices():
send_sync_error(e, invoice)
+def _delete_receipts():
+ """Delete the receipts that have been marked for deletion from moneybird."""
+ receipts = MoneybirdReceipt.objects.filter(needs_deletion=True)
+ logger.info("Deleting %d receipts.", receipts.count())
+
+ for receipt in receipts:
+ delete_receipt(receipt=receipt)
+
+
def _sync_outdated_invoices():
"""Resynchronize all invoices that have been marked as outdated."""
invoices = MoneybirdExternalInvoice.objects.filter(
@@ -477,6 +548,10 @@ def _sync_event_registrations():
send_sync_error(e, instance)
+# def _sync_receipts():
+# TODO
+
+
def _sync_moneybird_payments():
"""Create financial statements with all payments that haven't been synced yet.
diff --git a/website/reimbursements/admin.py b/website/reimbursements/admin.py
index fe754c166..fb4084f6d 100644
--- a/website/reimbursements/admin.py
+++ b/website/reimbursements/admin.py
@@ -9,9 +9,8 @@
@admin.register(models.Reimbursement)
class ReimbursementsAdmin(admin.ModelAdmin):
list_display = (
- "id",
- "created",
"owner",
+ "created",
"date_incurred",
"amount",
"verdict",
diff --git a/website/reimbursements/models.py b/website/reimbursements/models.py
index 461ab170d..2ee8030ce 100644
--- a/website/reimbursements/models.py
+++ b/website/reimbursements/models.py
@@ -33,7 +33,7 @@ class Verdict(models.TextChoices):
validators=[MinLengthValidator(10)],
)
- # explicitely chose for FileField over an ImageField because companies often send invoices as pdf
+ # explicitly chose for FileField over an ImageField because companies often send invoices as pdf
receipt = models.FileField(
upload_to=get_upload_to_function("reimbursements/receipts"),
diff --git a/website/reimbursements/services.py b/website/reimbursements/services.py
index a8edfd30d..7491b9655 100644
--- a/website/reimbursements/services.py
+++ b/website/reimbursements/services.py
@@ -10,24 +10,20 @@
def execute_data_minimisation(dry_run=False):
- old_declined_reimbursements = Reimbursement.objects.filter(
- verdict=Reimbursement.Verdict.DENIED,
- created__lt=timezone.now() - YEAR * 2,
- )
-
- logger.info(
- "Deleting %d declined reimbursements", old_declined_reimbursements.count()
- )
- if not dry_run:
- old_declined_reimbursements.delete()
-
- old_approved_reimbursements = Reimbursement.objects.filter(
- verdict=Reimbursement.Verdict.APPROVED,
- created__lt=timezone.now() - YEAR * 7,
- )
-
- logger.info(
- "Deleting %d approved reimbursements", old_approved_reimbursements.count()
- )
- if not dry_run:
- old_approved_reimbursements.delete()
+ def _delete_old_reimbursements(
+ verdict: Reimbursement.Verdict,
+ years_until_deletion: int,
+ ):
+ old_reimbursements = Reimbursement.objects.filter(
+ verdict=verdict,
+ created__lt=timezone.now() - YEAR * years_until_deletion,
+ )
+
+ logger.info(
+ "Deleting %d %s reimbursements", old_reimbursements.count(), verdict
+ )
+ if not dry_run:
+ old_reimbursements.delete()
+
+ _delete_old_reimbursements(Reimbursement.Verdict.DENIED, years_until_deletion=2)
+ _delete_old_reimbursements(Reimbursement.Verdict.APPROVED, years_until_deletion=7)