Coverage for website/payments/models.py: 100.00%
202 statements
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
1import datetime
2import uuid
3from decimal import Decimal
5from django.conf import settings
6from django.contrib.contenttypes.models import ContentType
7from django.core.exceptions import ValidationError
8from django.db import models
9from django.db.models import DEFERRED, BooleanField, Q, Sum
10from django.db.models.expressions import Case, Exists, OuterRef, Value, When
11from django.db.models.functions import Coalesce
12from django.urls import reverse
13from django.utils import timezone
14from django.utils.translation import gettext_lazy as _
16from localflavor.generic.countries.sepa import IBAN_SEPA_COUNTRIES
17from localflavor.generic.models import BICField, IBANField
18from queryable_properties.managers import QueryablePropertiesManager
19from queryable_properties.properties import AggregateProperty, queryable_property
21from members.models import Member
23from . import payables
26def validate_not_zero(value):
27 if value == 0:
28 raise ValidationError(
29 _("This field may not be 0."),
30 )
33class PaymentAmountField(models.DecimalField):
34 MAX_DIGITS = 8
35 DECIMAL_PLACES = 2
37 def __init__(self, **kwargs):
38 kwargs["max_digits"] = PaymentAmountField.MAX_DIGITS
39 kwargs["decimal_places"] = PaymentAmountField.DECIMAL_PLACES
40 allow_zero = kwargs.pop("allow_zero", False)
41 validators = kwargs.pop("validators", [])
42 if not allow_zero and validate_not_zero not in validators:
43 validators.append(validate_not_zero)
44 kwargs["validators"] = validators
45 super().__init__(**kwargs)
48class PaymentUser(Member):
49 class Meta:
50 proxy = True
51 verbose_name = "payment user"
53 objects = QueryablePropertiesManager()
55 @queryable_property(annotation_based=True)
56 @classmethod
57 def tpay_enabled(cls):
58 today = timezone.now().date()
59 return Case(
60 When(
61 Exists(
62 BankAccount.objects.filter(owner=OuterRef("pk")).filter(
63 Q(
64 valid_from__isnull=False,
65 valid_from__lte=today,
66 )
67 & (Q(valid_until__isnull=True) | Q(valid_until__gt=today))
68 )
69 ),
70 then=settings.THALIA_PAY_ENABLED_PAYMENT_METHOD,
71 ),
72 default=False,
73 output_field=BooleanField(),
74 )
76 tpay_balance = AggregateProperty(
77 -1
78 * Coalesce(
79 Sum(
80 "paid_payment_set__amount",
81 filter=Q(paid_payment_set__type="tpay_payment")
82 & (
83 Q(paid_payment_set__batch__isnull=True)
84 | Q(paid_payment_set__batch__processed=False)
85 ),
86 ),
87 Value(0.00),
88 output_field=PaymentAmountField(),
89 )
90 )
92 @queryable_property(annotation_based=True)
93 @classmethod
94 def tpay_allowed(cls):
95 return Case(
96 When(blacklistedpaymentuser__isnull=False, then=False),
97 default=True,
98 output_field=BooleanField(),
99 )
101 def allow_tpay(self):
102 """Give this user Thalia Pay permission."""
103 deleted, _ = BlacklistedPaymentUser.objects.filter(payment_user=self).delete()
104 return deleted > 0
106 def disallow_tpay(self):
107 """Revoke this user's Thalia Pay permission."""
108 _, created = BlacklistedPaymentUser.objects.get_or_create(payment_user=self)
109 return created
112class BlacklistedPaymentUser(models.Model):
113 payment_user = models.OneToOneField(
114 PaymentUser,
115 on_delete=models.CASCADE,
116 )
118 def __str__(self):
119 return f"{self.payment_user} (blacklisted from using Thalia Pay)"
122class Payment(models.Model):
123 """Describes a payment."""
125 id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
127 created_at = models.DateTimeField(_("created at"), default=timezone.now)
129 CASH = "cash_payment"
130 CARD = "card_payment"
131 TPAY = "tpay_payment"
132 WIRE = "wire_payment"
134 PAYMENT_TYPE = (
135 (CASH, _("Cash payment")),
136 (CARD, _("Card payment")),
137 (TPAY, _("Thalia Pay payment")),
138 (WIRE, _("Wire payment")),
139 )
141 type = models.CharField(
142 verbose_name=_("type"),
143 blank=False,
144 null=False,
145 max_length=20,
146 choices=PAYMENT_TYPE,
147 )
149 amount = PaymentAmountField(
150 verbose_name=_("amount"),
151 blank=False,
152 null=False,
153 )
155 paid_by = models.ForeignKey(
156 "members.Member",
157 models.PROTECT,
158 verbose_name=_("paid by"),
159 related_name="paid_payment_set",
160 blank=True,
161 null=True,
162 )
164 processed_by = models.ForeignKey(
165 "members.Member",
166 models.PROTECT,
167 verbose_name=_("processed by"),
168 related_name="processed_payment_set",
169 blank=False,
170 null=True,
171 )
173 batch = models.ForeignKey(
174 "payments.Batch",
175 models.PROTECT,
176 related_name="payments_set",
177 blank=True,
178 null=True,
179 )
181 notes = models.TextField(verbose_name=_("notes"), blank=True, null=True)
182 topic = models.CharField(verbose_name=_("topic"), max_length=255, default="Unknown")
184 @classmethod
185 def get_payable_prefetches(cls):
186 """Return all (OneToOneField reverse relation) fields from payables to `Payment`.
188 This can be used to prefetch all payable models related to a payment, which makes
189 the `Payment.payable_object` property much faster when called on multiple payments.
191 Usage:
192 >>> Payment.objects.prefetch_related(*Payment.get_payable_prefetches())
193 """
194 return [
195 model._meta.get_field("payment").related_query_name()
196 for model in payables.payables.get_payable_models()
197 ]
199 @property
200 def payable_object(self) -> models.Model | None:
201 """Return the payable model instance associated with this payment.
203 This is based on the reverse relations of the OneToOneFields from all
204 payable models to `Payment`.
206 This property will perform many queries if the related payable models
207 have not been prefetched. Prefetch them with:
209 >>> Payment.objects.prefetch_related(*Payment.get_payable_prefetches())
210 """
211 for model in payables.payables.get_payable_models():
212 if hasattr(self, model._meta.get_field("payment").related_query_name()):
213 return getattr(
214 self, model._meta.get_field("payment").related_query_name()
215 )
216 return None
218 def __init__(self, *args, **kwargs):
219 super().__init__(*args, **kwargs)
221 # This is a pretty ugly hack, but it's necessary to something like this
222 # when you want to check things against the old version of the model in
223 # the clean() method---so some of the old data is saved here in init.
225 # Previously we had something like this:
226 # self._batch = self.batch
227 # but this breaks when deleting the Payment instance, and creates a
228 # stack overflow crash.
230 # Instead we try to get the foreign key out of the init function arguments.
231 # This works because when Django does database lookups via the .objects
232 # manager, it uses the init method to create this model instance and it
233 # includes the id. When we create a Payment in our own code we also use
234 # this init method but use keyword arguments most of the time.
236 # In the future we might want to remove this code and instead move
237 # cleaning code to a place where we do have the old information available.
238 self._batch_id = None
239 if not kwargs:
240 batch_id_idx = [
241 i
242 for i, x in enumerate(self._meta.concrete_fields)
243 if x.attname == "batch_id"
244 ]
245 if (
246 batch_id_idx
247 and len(args) >= batch_id_idx[0]
248 and args[batch_id_idx[0]] != DEFERRED
249 ):
250 self._batch_id = args[batch_id_idx[0]]
251 else:
252 # This should be okay as keyword arguments are only used for manual
253 # instantiation.
254 self._batch_id = self.batch_id
256 self._type = self.type
258 def save(self, **kwargs):
259 self.clean()
260 self._batch_id = self.batch.id if self.batch else None
261 super().save(**kwargs)
263 def clean(self):
264 if self.amount == 0:
265 raise ValidationError({"amount": f"Payments cannot be €{self.amount}"})
266 if self.type != Payment.TPAY and self.batch is not None:
267 raise ValidationError(
268 {"batch": _("Non Thalia Pay payments cannot be added to a batch")}
269 )
270 if self._batch_id and Batch.objects.get(pk=self._batch_id).processed:
271 raise ValidationError(
272 _("Cannot change a payment that is part of a processed batch")
273 )
274 if self.batch and self.batch.processed:
275 raise ValidationError(_("Cannot add a payment to a processed batch"))
276 if self.paid_by is None and self.type == Payment.TPAY:
277 raise ValidationError("Thalia Pay payments must have a payer.")
278 elif (
279 (self._state.adding or self._type != Payment.TPAY)
280 and self.type == Payment.TPAY
281 and not PaymentUser.objects.select_properties("tpay_enabled")
282 .filter(pk=self.paid_by.pk, tpay_enabled=True)
283 .exists()
284 ):
285 raise ValidationError(
286 {"paid_by": _("This user does not have Thalia Pay enabled")}
287 )
289 def get_admin_url(self):
290 content_type = ContentType.objects.get_for_model(self.__class__)
291 return reverse(
292 f"admin:{content_type.app_label}_{content_type.model}_change",
293 args=(self.id,),
294 )
296 class Meta:
297 verbose_name = _("payment")
298 verbose_name_plural = _("payments")
300 def __str__(self):
301 return _("Payment of {amount:.2f}").format(amount=self.amount)
304def _default_batch_description():
305 now = timezone.now()
306 return f"Thalia Pay payments for {now.year}-{now.month}"
309def _default_withdrawal_date():
310 return (
311 timezone.now() + settings.PAYMENT_BATCH_DEFAULT_WITHDRAWAL_DATE_OFFSET
312 ).date()
315class Batch(models.Model):
316 """Describes a batch of payments for export."""
318 processed = models.BooleanField(
319 verbose_name=_("processing status"),
320 default=False,
321 )
323 processing_date = models.DateTimeField(
324 verbose_name=_("processing date"),
325 blank=True,
326 null=True,
327 )
329 description = models.TextField(
330 verbose_name=_("description"),
331 default=_default_batch_description,
332 )
334 withdrawal_date = models.DateField(
335 verbose_name=_("withdrawal date"),
336 null=False,
337 blank=False,
338 default=_default_withdrawal_date,
339 )
341 def save(
342 self, force_insert=False, force_update=False, using=None, update_fields=None
343 ):
344 if self.processed and not self.processing_date:
345 self.processing_date = timezone.now()
346 super().save(force_insert, force_update, using, update_fields)
348 def get_absolute_url(self):
349 return reverse("admin:payments_batch_change", args=[str(self.pk)])
351 def start_date(self) -> datetime.datetime:
352 return self.payments_set.earliest("created_at").created_at
354 start_date.admin_order_field = "first payment in batch"
355 start_date.short_description = _("first payment in batch")
357 def end_date(self) -> datetime.datetime:
358 return self.payments_set.latest("created_at").created_at
360 end_date.admin_order_field = "last payment in batch"
361 end_date.short_description = _("last payment in batch")
363 def total_amount(self) -> Decimal:
364 return sum(payment.amount for payment in self.payments_set.all())
366 total_amount.admin_order_field = "total amount"
367 total_amount.short_description = _("total amount")
369 def payments_count(self) -> Decimal:
370 return self.payments_set.all().count()
372 payments_count.admin_order_field = "payments count"
373 payments_count.short_description = _("payments count")
375 class Meta:
376 verbose_name = _("batch")
377 verbose_name_plural = _("batches")
378 permissions = (("process_batches", _("Process batch")),)
380 def __str__(self):
381 return (
382 f"{self.description} "
383 f"({'processed' if self.processed else 'not processed'})"
384 )
387class BankAccount(models.Model):
388 """Describes a bank account."""
390 id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
392 created_at = models.DateTimeField(_("created at"), default=timezone.now)
394 last_used = models.DateField(
395 verbose_name=_("last used"),
396 blank=True,
397 null=True,
398 )
400 owner = models.ForeignKey(
401 to=PaymentUser,
402 verbose_name=_("owner"),
403 related_name="bank_accounts",
404 on_delete=models.SET_NULL,
405 blank=False,
406 null=True,
407 )
409 initials = models.CharField(
410 verbose_name=_("initials"),
411 max_length=20,
412 )
414 last_name = models.CharField(
415 verbose_name=_("last name"),
416 max_length=255,
417 )
419 iban = IBANField(
420 verbose_name=_("IBAN"),
421 include_countries=IBAN_SEPA_COUNTRIES,
422 )
424 bic = BICField(
425 verbose_name=_("BIC"),
426 blank=True,
427 null=True,
428 help_text=_("This field is optional for Dutch bank accounts."),
429 )
431 valid_from = models.DateField(
432 verbose_name=_("valid from"),
433 blank=True,
434 null=True,
435 )
437 valid_until = models.DateField(
438 verbose_name=_("valid until"),
439 blank=True,
440 null=True,
441 help_text=_(
442 "Users can revoke the mandate at any time, as long as they do not have any Thalia Pay payments that have not been processed. If you revoke a mandate, make sure to check that all unprocessed Thalia Pay payments are paid in an alternative manner."
443 ),
444 )
446 signature = models.TextField(
447 verbose_name=_("signature"),
448 blank=True,
449 null=True,
450 )
452 MANDATE_NO_DEFAULT_REGEX = r"^\d+-\d+$"
453 mandate_no = models.CharField(
454 verbose_name=_("mandate number"),
455 max_length=255,
456 blank=True,
457 null=True,
458 unique=True,
459 )
461 def clean(self):
462 super().clean()
463 errors = {}
465 if self.iban[0:2] != "NL" and not self.bic:
466 errors["bic"] = _("This field is required for foreign bank accounts.")
468 if not self.owner:
469 errors["owner"] = _("This field is required.")
471 mandate_fields = [
472 ("valid_from", self.valid_from),
473 ("signature", self.signature),
474 ("mandate_no", self.mandate_no),
475 ]
477 if any(not field[1] for field in mandate_fields) and any(
478 field[1] for field in mandate_fields
479 ):
480 for field in mandate_fields:
481 if not field[1]:
482 errors.update(
483 {field[0]: _("This field is required to complete the mandate.")}
484 )
486 if self.valid_from and self.valid_until and self.valid_from > self.valid_until:
487 errors.update(
488 {"valid_until": _("This date cannot be before the from date.")}
489 )
491 if self.valid_until and not self.valid_from:
492 errors.update({"valid_until": _("This field cannot have a value.")})
494 if errors:
495 raise ValidationError(errors)
497 @property
498 def name(self):
499 return f"{self.initials} {self.last_name}"
501 @property
502 def can_be_revoked(self):
503 return not self.owner.paid_payment_set.filter(
504 (Q(batch__isnull=True) | Q(batch__processed=False)) & Q(type=Payment.TPAY)
505 ).exists()
507 @property
508 def valid(self):
509 if self.valid_from is not None and self.valid_until is not None:
510 return self.valid_from <= timezone.now().date() < self.valid_until
511 return self.valid_from is not None and self.valid_from <= timezone.now().date()
513 def __str__(self):
514 return f"{self.iban} - {self.name}"
516 class Meta:
517 ordering = ("created_at",)