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)