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

1import datetime 

2import uuid 

3from decimal import Decimal 

4 

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 _ 

15 

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 

20 

21from members.models import Member 

22 

23from . import payables 

24 

25 

26def validate_not_zero(value): 

27 if value == 0: 

28 raise ValidationError( 

29 _("This field may not be 0."), 

30 ) 

31 

32 

33class PaymentAmountField(models.DecimalField): 

34 MAX_DIGITS = 8 

35 DECIMAL_PLACES = 2 

36 

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) 

46 

47 

48class PaymentUser(Member): 

49 class Meta: 

50 proxy = True 

51 verbose_name = "payment user" 

52 

53 objects = QueryablePropertiesManager() 

54 

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 ) 

75 

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 ) 

91 

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 ) 

100 

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 

105 

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 

110 

111 

112class BlacklistedPaymentUser(models.Model): 

113 payment_user = models.OneToOneField( 

114 PaymentUser, 

115 on_delete=models.CASCADE, 

116 ) 

117 

118 def __str__(self): 

119 return f"{self.payment_user} (blacklisted from using Thalia Pay)" 

120 

121 

122class Payment(models.Model): 

123 """Describes a payment.""" 

124 

125 id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 

126 

127 created_at = models.DateTimeField(_("created at"), default=timezone.now) 

128 

129 CASH = "cash_payment" 

130 CARD = "card_payment" 

131 TPAY = "tpay_payment" 

132 WIRE = "wire_payment" 

133 

134 PAYMENT_TYPE = ( 

135 (CASH, _("Cash payment")), 

136 (CARD, _("Card payment")), 

137 (TPAY, _("Thalia Pay payment")), 

138 (WIRE, _("Wire payment")), 

139 ) 

140 

141 type = models.CharField( 

142 verbose_name=_("type"), 

143 blank=False, 

144 null=False, 

145 max_length=20, 

146 choices=PAYMENT_TYPE, 

147 ) 

148 

149 amount = PaymentAmountField( 

150 verbose_name=_("amount"), 

151 blank=False, 

152 null=False, 

153 ) 

154 

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 ) 

163 

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 ) 

172 

173 batch = models.ForeignKey( 

174 "payments.Batch", 

175 models.PROTECT, 

176 related_name="payments_set", 

177 blank=True, 

178 null=True, 

179 ) 

180 

181 notes = models.TextField(verbose_name=_("notes"), blank=True, null=True) 

182 topic = models.CharField(verbose_name=_("topic"), max_length=255, default="Unknown") 

183 

184 @classmethod 

185 def get_payable_prefetches(cls): 

186 """Return all (OneToOneField reverse relation) fields from payables to `Payment`. 

187 

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. 

190 

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 ] 

198 

199 @property 

200 def payable_object(self) -> models.Model | None: 

201 """Return the payable model instance associated with this payment. 

202 

203 This is based on the reverse relations of the OneToOneFields from all 

204 payable models to `Payment`. 

205 

206 This property will perform many queries if the related payable models 

207 have not been prefetched. Prefetch them with: 

208 

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 

217 

218 def __init__(self, *args, **kwargs): 

219 super().__init__(*args, **kwargs) 

220 

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. 

224 

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. 

229 

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. 

235 

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 

255 

256 self._type = self.type 

257 

258 def save(self, **kwargs): 

259 self.clean() 

260 self._batch_id = self.batch.id if self.batch else None 

261 super().save(**kwargs) 

262 

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 ) 

288 

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 ) 

295 

296 class Meta: 

297 verbose_name = _("payment") 

298 verbose_name_plural = _("payments") 

299 

300 def __str__(self): 

301 return _("Payment of {amount:.2f}").format(amount=self.amount) 

302 

303 

304def _default_batch_description(): 

305 now = timezone.now() 

306 return f"Thalia Pay payments for {now.year}-{now.month}" 

307 

308 

309def _default_withdrawal_date(): 

310 return ( 

311 timezone.now() + settings.PAYMENT_BATCH_DEFAULT_WITHDRAWAL_DATE_OFFSET 

312 ).date() 

313 

314 

315class Batch(models.Model): 

316 """Describes a batch of payments for export.""" 

317 

318 processed = models.BooleanField( 

319 verbose_name=_("processing status"), 

320 default=False, 

321 ) 

322 

323 processing_date = models.DateTimeField( 

324 verbose_name=_("processing date"), 

325 blank=True, 

326 null=True, 

327 ) 

328 

329 description = models.TextField( 

330 verbose_name=_("description"), 

331 default=_default_batch_description, 

332 ) 

333 

334 withdrawal_date = models.DateField( 

335 verbose_name=_("withdrawal date"), 

336 null=False, 

337 blank=False, 

338 default=_default_withdrawal_date, 

339 ) 

340 

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) 

347 

348 def get_absolute_url(self): 

349 return reverse("admin:payments_batch_change", args=[str(self.pk)]) 

350 

351 def start_date(self) -> datetime.datetime: 

352 return self.payments_set.earliest("created_at").created_at 

353 

354 start_date.admin_order_field = "first payment in batch" 

355 start_date.short_description = _("first payment in batch") 

356 

357 def end_date(self) -> datetime.datetime: 

358 return self.payments_set.latest("created_at").created_at 

359 

360 end_date.admin_order_field = "last payment in batch" 

361 end_date.short_description = _("last payment in batch") 

362 

363 def total_amount(self) -> Decimal: 

364 return sum(payment.amount for payment in self.payments_set.all()) 

365 

366 total_amount.admin_order_field = "total amount" 

367 total_amount.short_description = _("total amount") 

368 

369 def payments_count(self) -> Decimal: 

370 return self.payments_set.all().count() 

371 

372 payments_count.admin_order_field = "payments count" 

373 payments_count.short_description = _("payments count") 

374 

375 class Meta: 

376 verbose_name = _("batch") 

377 verbose_name_plural = _("batches") 

378 permissions = (("process_batches", _("Process batch")),) 

379 

380 def __str__(self): 

381 return ( 

382 f"{self.description} " 

383 f"({'processed' if self.processed else 'not processed'})" 

384 ) 

385 

386 

387class BankAccount(models.Model): 

388 """Describes a bank account.""" 

389 

390 id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 

391 

392 created_at = models.DateTimeField(_("created at"), default=timezone.now) 

393 

394 last_used = models.DateField( 

395 verbose_name=_("last used"), 

396 blank=True, 

397 null=True, 

398 ) 

399 

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 ) 

408 

409 initials = models.CharField( 

410 verbose_name=_("initials"), 

411 max_length=20, 

412 ) 

413 

414 last_name = models.CharField( 

415 verbose_name=_("last name"), 

416 max_length=255, 

417 ) 

418 

419 iban = IBANField( 

420 verbose_name=_("IBAN"), 

421 include_countries=IBAN_SEPA_COUNTRIES, 

422 ) 

423 

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 ) 

430 

431 valid_from = models.DateField( 

432 verbose_name=_("valid from"), 

433 blank=True, 

434 null=True, 

435 ) 

436 

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 ) 

445 

446 signature = models.TextField( 

447 verbose_name=_("signature"), 

448 blank=True, 

449 null=True, 

450 ) 

451 

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 ) 

460 

461 def clean(self): 

462 super().clean() 

463 errors = {} 

464 

465 if self.iban[0:2] != "NL" and not self.bic: 

466 errors["bic"] = _("This field is required for foreign bank accounts.") 

467 

468 if not self.owner: 

469 errors["owner"] = _("This field is required.") 

470 

471 mandate_fields = [ 

472 ("valid_from", self.valid_from), 

473 ("signature", self.signature), 

474 ("mandate_no", self.mandate_no), 

475 ] 

476 

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 ) 

485 

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 ) 

490 

491 if self.valid_until and not self.valid_from: 

492 errors.update({"valid_until": _("This field cannot have a value.")}) 

493 

494 if errors: 

495 raise ValidationError(errors) 

496 

497 @property 

498 def name(self): 

499 return f"{self.initials} {self.last_name}" 

500 

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() 

506 

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() 

512 

513 def __str__(self): 

514 return f"{self.iban} - {self.name}" 

515 

516 class Meta: 

517 ordering = ("created_at",)