Repository: svthalia/concrexit

Issue:
Display current amount of Thalia Pay debts on PaymentProcessView page
### Is your feature request related to a problem? Please describe.
It should be clear to people, every time they pay with Thalia Pay, what their debts are. Also it is nice for the board to have this information at hand. 

### Describe the solution you'd like
- A user property that calculates the sum of all unprocessed Thalia Pay payments for that user
- Display that amount on the page where users confirm a Thalia Pay payment: "After this payment, your Thalia Pay balance will be -€ xx.xx"

### Motivation

### Describe alternatives you've considered

### Additional context



Discussion:
Maybe also nice to show it like this
<img width="313" alt="image" src="https://user-images.githubusercontent.com/7915741/94549345-59acc200-0252-11eb-8f4f-2221da60502f.png">


Resolving patch:
diff --git a/website/payments/admin.py b/website/payments/admin.py
--- a/website/payments/admin.py
+++ b/website/payments/admin.py
@@ -531,12 +531,145 @@ def export_csv(self, request: HttpRequest, queryset: QuerySet) -> HttpResponse:
     export_csv.short_description = _("Export")
 
 
+class BankAccountInline(admin.TabularInline):
+    model = BankAccount
+    fields = (
+        "iban",
+        "bic",
+        "mandate_no",
+        "valid_from",
+        "valid_until",
+        "last_used",
+    )
+    show_change_link = True
+
+    can_delete = False
+
+    def has_add_permission(self, request, obj=None):
+        return False
+
+    def has_change_permission(self, request, obj=None):
+        return False
+
+
+class PaymentInline(admin.TabularInline):
+    model = Payment
+    fields = (
+        "created_at",
+        "type",
+        "amount",
+        "topic",
+        "notes",
+        "batch",
+    )
+
+    show_change_link = True
+
+    can_delete = False
+
+    def has_add_permission(self, request, obj=None):
+        return False
+
+    def has_change_permission(self, request, obj=None):
+        return False
+
+
+class ThaliaPayEnabledFilter(admin.SimpleListFilter):
+    title = _("Thalia Pay enabled")
+    parameter_name = "tpay_enabled"
+
+    def lookups(self, request, model_admin):
+        return ("1", _("Yes")), ("0", _("No"))
+
+    def queryset(self, request, queryset):
+        tpay_enabled = [x.id for x in queryset.all() if x.tpay_enabled]
+        if self.value() == "1":
+            return queryset.filter(id__in=tpay_enabled)
+        if self.value() == "0":
+            return queryset.exclude(id__in=tpay_enabled)
+        return queryset
+
+
+class ThaliaPayBalanceFilter(admin.SimpleListFilter):
+    title = _("Thalia Pay balance")
+    parameter_name = "tpay_balance"
+
+    def lookups(self, request, model_admin):
+        return (
+            ("0", "€0,00"),
+            ("1", ">€0.00"),
+        )
+
+    def queryset(self, request, queryset):
+        tpay_balance = [x.id for x in queryset.all() if x.tpay_balance != 0]
+        if self.value() == "0":
+            return queryset.exclude(id__in=tpay_balance)
+        if self.value() == "1":
+            return queryset.filter(id__in=tpay_balance)
+        return queryset
+
+
 @admin.register(PaymentUser)
 class PaymentUserAdmin(admin.ModelAdmin):
+    list_display = (
+        "__str__",
+        "email",
+        "get_tpay_enabled",
+        "get_tpay_balance",
+    )
+    list_filter = [ThaliaPayEnabledFilter, ThaliaPayBalanceFilter]
+
+    inlines = [BankAccountInline, PaymentInline]
+
+    fields = (
+        "user_link",
+        "get_tpay_enabled",
+        "get_tpay_balance",
+    )
+
+    readonly_fields = (
+        "user_link",
+        "get_tpay_enabled",
+        "get_tpay_balance",
+    )
 
     search_fields = (
         "first_name",
         "last_name",
-        "email",
         "username",
+        "email",
     )
+
+    def get_tpay_balance(self, obj):
+        return f"€ {obj.tpay_balance:.2f}"
+
+    get_tpay_balance.short_description = _("balance")
+
+    def get_tpay_enabled(self, obj):
+        return obj.tpay_enabled
+
+    get_tpay_enabled.short_description = _("Thalia Pay enabled")
+    get_tpay_enabled.boolean = True
+
+    def user_link(self, obj):
+        return (
+            format_html(
+                "<a href='{}'>{}</a>",
+                reverse("admin:auth_user_change", args=[obj.pk]),
+                obj.get_full_name(),
+            )
+            if obj
+            else ""
+        )
+
+    user_link.admin_order_field = "user"
+    user_link.short_description = _("user")
+
+    def has_add_permission(self, request, obj=None):
+        return False
+
+    def has_change_permission(self, request, obj=None):
+        return False
+
+    def has_delete_permission(self, request, obj=None):
+        return False
diff --git a/website/payments/models.py b/website/payments/models.py
--- a/website/payments/models.py
+++ b/website/payments/models.py
@@ -7,7 +7,7 @@
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.db import models
-from django.db.models import Q
+from django.db.models import Q, Sum
 from django.urls import reverse
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
@@ -32,6 +32,16 @@ def tpay_enabled(self):
             and any(x.valid for x in bank_accounts)
         )
 
+    @property
+    def tpay_balance(self):
+        """Checks the Thalia Pay balance for a user"""
+        payments = Payment.objects.filter(
+            Q(paid_by=self, type=Payment.TPAY)
+            & (Q(batch__isnull=True) | Q(batch__processed=False))
+        )
+        total = payments.aggregate(Sum("amount"))["amount__sum"]
+        return -1 * total if total else 0
+
 
 class Payment(models.Model):
     """
diff --git a/website/payments/views.py b/website/payments/views.py
--- a/website/payments/views.py
+++ b/website/payments/views.py
@@ -1,3 +1,5 @@
+from decimal import Decimal
+
 from dateutil.relativedelta import relativedelta
 from django.apps import apps
 from django.conf import settings
@@ -152,6 +154,9 @@ def get_context_data(self, *args, **kwargs):
                 "total": context["object_list"]
                 .aggregate(Sum("amount"))
                 .get("amount__sum"),
+                "tpay_balance": PaymentUser.objects.get(
+                    pk=self.request.member.pk
+                ).tpay_balance,
                 "year": self.kwargs.get("year", timezone.now().year),
                 "month": self.kwargs.get("month", timezone.now().month),
             }
@@ -184,6 +189,14 @@ def dispatch(self, request, *args, **kwargs):
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
         context.update({"payable": self.payable})
+        context.update(
+            {
+                "new_balance": PaymentUser.objects.get(
+                    pk=self.payable.payment_payer.pk
+                ).tpay_balance
+                - Decimal(self.payable.payment_amount)
+            }
+        )
         return context
 
     def post(self, request, *args, **kwargs):


Test patch:
diff --git a/website/payments/tests/test_admin.py b/website/payments/tests/test_admin.py
--- a/website/payments/tests/test_admin.py
+++ b/website/payments/tests/test_admin.py
@@ -1,5 +1,6 @@
+from decimal import Decimal
 from unittest import mock
-from unittest.mock import Mock, MagicMock
+from unittest.mock import Mock, MagicMock, PropertyMock
 
 from django.contrib import messages
 from django.contrib.admin import AdminSite
@@ -22,7 +23,7 @@
 
 from members.models import Member, Profile
 from payments import admin
-from payments.admin import ValidAccountFilter
+from payments.admin import ValidAccountFilter, BankAccountInline, PaymentInline
 from payments.forms import BatchPaymentInlineAdminForm
 from payments.models import Payment, BankAccount, Batch, PaymentUser
 
@@ -851,3 +852,156 @@ def test_set_last_used(self, update_last_used, message_user) -> None:
             % {"count": 1, "items": model_ngettext(BankAccount(), 1)},
             messages.SUCCESS,
         )
+
+
+@freeze_time("2019-01-01")
+@override_settings(SUSPEND_SIGNALS=True, THALIA_PAY_ENABLED_PAYMENT_METHOD=True)
+class PaymentUserAdminTest(TestCase):
+    fixtures = ["members.json", "bank_accounts.json"]
+
+    @classmethod
+    def setUpTestData(cls) -> None:
+        cls.user = Member.objects.get(pk=2)
+
+    def setUp(self) -> None:
+        self.client = Client()
+        self.client.force_login(self.user)
+        self.site = AdminSite()
+        self.admin = admin.PaymentUserAdmin(PaymentUser, admin_site=self.site)
+        self.rf = RequestFactory()
+        self.user = PaymentUser.objects.first()
+
+    def test_has_add_permissions(self):
+        request = self.rf.get(reverse("admin:payments_paymentuser_add"))
+        request.user = self.user
+        self.assertFalse(self.admin.has_add_permission(request))
+
+    def test_has_delete_permissions(self):
+        request = self.rf.get(
+            reverse("admin:payments_paymentuser_delete", args=[self.user.pk])
+        )
+        request.user = self.user
+        self.assertFalse(self.admin.has_delete_permission(request))
+
+    @mock.patch("payments.models.PaymentUser.tpay_balance", new_callable=PropertyMock)
+    def test_get_tpay_balance(self, tpay_balance):
+        tpay_balance.return_value = Decimal(-10)
+        self.assertEquals(self.admin.get_tpay_balance(self.user), "€ -10.00")
+
+    @mock.patch("payments.models.PaymentUser.tpay_enabled", new_callable=PropertyMock)
+    def test_get_tpay_enabled(self, tpay_enabled):
+        tpay_enabled.return_value = True
+        self.assertEquals(self.admin.get_tpay_enabled(self.user), True)
+
+    def test_tpay_enabled_filter(self):
+        filter_all = admin.ThaliaPayEnabledFilter(
+            None, {}, PaymentUser, admin.PaymentUserAdmin
+        )
+        self.assertEqual(
+            filter_all.queryset(None, PaymentUser.objects), PaymentUser.objects
+        )
+
+        filter_true = admin.ThaliaPayEnabledFilter(
+            None, {"tpay_enabled": "1"}, PaymentUser, admin.PaymentUserAdmin
+        )
+        self.assertQuerysetEqual(
+            filter_true.queryset(None, PaymentUser.objects)
+            .values_list("pk", flat=True)
+            .all(),
+            ["2", "1"],
+        )
+        filter_false = admin.ThaliaPayEnabledFilter(
+            None, {"tpay_enabled": "0"}, PaymentUser, admin.PaymentUserAdmin
+        )
+        self.assertQuerysetEqual(
+            filter_false.queryset(None, PaymentUser.objects)
+            .values_list("pk", flat=True)
+            .all(),
+            ["3", "4"],
+        )
+
+    @mock.patch("payments.models.PaymentUser.tpay_balance", new_callable=PropertyMock)
+    def test_tpay_balance_filter(self, tpay_balance):
+        filter_all = admin.ThaliaPayBalanceFilter(
+            None, {}, PaymentUser, admin.PaymentUserAdmin
+        )
+        self.assertEqual(
+            filter_all.queryset(None, PaymentUser.objects), PaymentUser.objects
+        )
+
+        tpay_balance.return_value = Decimal(0)
+        filter_true = admin.ThaliaPayBalanceFilter(
+            None, {"tpay_balance": "0"}, PaymentUser, admin.PaymentUserAdmin
+        )
+        self.assertQuerysetEqual(
+            filter_true.queryset(None, PaymentUser.objects)
+            .values_list("pk", flat=True)
+            .all(),
+            ["3", "4", "2", "1"],
+        )
+        filter_false = admin.ThaliaPayBalanceFilter(
+            None, {"tpay_balance": "1"}, PaymentUser, admin.PaymentUserAdmin
+        )
+        self.assertQuerysetEqual(
+            filter_false.queryset(None, PaymentUser.objects)
+            .values_list("pk", flat=True)
+            .all(),
+            [],
+        )
+        tpay_balance.return_value = Decimal(10)
+        filter_true = admin.ThaliaPayBalanceFilter(
+            None, {"tpay_balance": "0"}, PaymentUser, admin.PaymentUserAdmin
+        )
+        self.assertQuerysetEqual(
+            filter_true.queryset(None, PaymentUser.objects)
+            .values_list("pk", flat=True)
+            .all(),
+            [],
+        )
+        filter_false = admin.ThaliaPayBalanceFilter(
+            None, {"tpay_balance": "1"}, PaymentUser, admin.PaymentUserAdmin
+        )
+        self.assertQuerysetEqual(
+            filter_false.queryset(None, PaymentUser.objects)
+            .values_list("pk", flat=True)
+            .all(),
+            ["3", "4", "2", "1"],
+        )
+
+    def test_user_link(self):
+        self.assertEqual(
+            self.admin.user_link(self.user),
+            f"<a href='/admin/auth/user/{self.user.pk}/change/'>{self.user.get_full_name()}</a>",
+        )
+
+    def test_bankaccount_inline_permissions(self):
+        request = self.rf.get(reverse("admin:payments_paymentuser_add"))
+        request.user = self.user
+        self.assertFalse(
+            BankAccountInline(BankAccount, self.admin.admin_site).has_add_permission(
+                request
+            )
+        )
+        self.assertFalse(
+            BankAccountInline(BankAccount, self.admin.admin_site).has_change_permission(
+                request
+            )
+        )
+        self.assertFalse(
+            BankAccountInline(BankAccount, self.admin.admin_site).has_delete_permission(
+                request
+            )
+        )
+
+    def test_payment_inline_permissions(self):
+        request = self.rf.get(reverse("admin:payments_paymentuser_add"))
+        request.user = self.user
+        self.assertFalse(
+            PaymentInline(Payment, self.admin.admin_site).has_add_permission(request)
+        )
+        self.assertFalse(
+            PaymentInline(Payment, self.admin.admin_site).has_change_permission(request)
+        )
+        self.assertFalse(
+            PaymentInline(Payment, self.admin.admin_site).has_delete_permission(request)
+        )
diff --git a/website/payments/tests/test_models.py b/website/payments/tests/test_models.py
--- a/website/payments/tests/test_models.py
+++ b/website/payments/tests/test_models.py
@@ -1,11 +1,14 @@
 import datetime
+from decimal import Decimal
 
 from django.core.exceptions import ValidationError
 from django.test import TestCase, override_settings
 from django.utils import timezone
 from freezegun import freeze_time
 
+from payments import services
 from payments.models import Payment, BankAccount, Batch, Payable, PaymentUser
+from payments.tests.__mocks__ import MockPayable
 
 
 class PayableTest(TestCase):
@@ -436,3 +439,39 @@ def test_tpay_enabled(self):
         b.valid_until = timezone.now().date() - timezone.timedelta(days=1)
         b.save()
         self.assertFalse(self.member.tpay_enabled)
+
+    def test_tpay_balance(self):
+        self.assertEqual(self.member.tpay_balance, 0)
+        BankAccount.objects.create(
+            owner=self.member,
+            initials="J",
+            last_name="Test2",
+            iban="NL91ABNA0417164300",
+            mandate_no="11-2",
+            valid_from=timezone.now().date() - timezone.timedelta(days=5),
+            last_used=timezone.now().date() - timezone.timedelta(days=5),
+            signature="base64,png",
+        )
+        p1 = services.create_payment(
+            MockPayable(self.member), self.member, Payment.TPAY
+        )
+        self.assertEqual(self.member.tpay_balance, Decimal(-5))
+
+        p2 = services.create_payment(
+            MockPayable(self.member), self.member, Payment.TPAY
+        )
+        self.assertEqual(self.member.tpay_balance, Decimal(-10))
+
+        batch = Batch.objects.create()
+        p1.batch = batch
+        p1.save()
+
+        p2.batch = batch
+        p2.save()
+
+        self.assertEqual(self.member.tpay_balance, Decimal(-10))
+
+        batch.processed = True
+        batch.save()
+
+        self.assertEqual(self.member.tpay_balance, 0)