Coverage for jbank/models.py: 79%
620 statements
« prev ^ index » next coverage.py v7.2.2, created at 2023-03-27 13:36 +0700
« prev ^ index » next coverage.py v7.2.2, created at 2023-03-27 13:36 +0700
1import base64
2import logging
3import os
4import re
5import subprocess
6import tempfile
7from datetime import datetime, time, date
8from decimal import Decimal
9from os.path import basename, join
10from pathlib import Path
11from typing import List, Optional
12import pytz
13from django.conf import settings
14from django.core.exceptions import ValidationError
15from django.db import models
16from django.db.models import Q
17from django.template.loader import get_template
18from django.utils.timezone import now
19from django.utils.translation import gettext_lazy as _
20from jacc.helpers import sum_queryset
21from jacc.models import AccountEntry, AccountEntrySourceFile, Account, AccountEntryManager
22from jbank.x509_helpers import get_x509_cert_from_file
23from jutil.modelfields import SafeCharField, SafeTextField
24from jutil.format import format_xml, get_media_full_path, choices_label
25from jutil.validators import iban_validator, iban_bic, iso_payment_reference_validator, fi_payment_reference_validator
28logger = logging.getLogger(__name__)
31JBANK_BIN_PATH = Path(__file__).absolute().parent.joinpath("bin")
33RECORD_ENTRY_TYPE = (
34 ("1", _("Deposit")),
35 ("2", _("Withdrawal")),
36 ("3", _("Deposit Correction")),
37 ("4", _("Withdrawal Correction")),
38)
40RECORD_CODES = (
41 ("700", _("Money Transfer (In/Out)")),
42 ("701", _("Recurring Payment (In/Out)")),
43 ("702", _("Bill Payment (Out)")),
44 ("703", _("Payment Terminal Deposit (In)")),
45 ("704", _("Bank Draft (In/Out)")),
46 ("705", _("Reference Payments (In)")),
47 ("706", _("Payment Service (Out)")),
48 ("710", _("Deposit (In)")),
49 ("720", _("Withdrawal (Out)")),
50 ("721", _("Card Payment (Out)")),
51 ("722", _("Check (Out)")),
52 ("730", _("Bank Fees (Out)")),
53 ("740", _("Interests Charged (Out)")),
54 ("750", _("Interests Credited (In)")),
55 ("760", _("Loan (Out)")),
56 ("761", _("Loan Payment (Out)")),
57 ("770", _("Foreign Transfer (In/Out)")),
58 ("780", _("Zero Balancing (In/Out)")),
59 ("781", _("Sweeping (In/Out)")),
60 ("782", _("Topping (In/Out)")),
61)
63RECORD_DOMAIN = (
64 ("PMNT", _("Money Transfer (In/Out)")),
65 ("LDAS", _("Loan Payment (Out)")),
66 ("CAMT", _("Cash Management")),
67 ("ACMT", _("Account Management")),
68 ("XTND", _("Entended Domain")),
69 ("SECU", _("Securities")),
70 ("FORX", _("Foreign Exchange")),
71 ("XTND", _("Entended Domain")),
72 ("NTAV", _("Not Available")),
73)
75RECEIPT_CODE = (
76 ("", ""),
77 ("0", "(0)"),
78 ("E", _("Separate")),
79 ("P", _("Separate/Paper")),
80)
82CURRENCY_IDENTIFIERS = (("1", "EUR"),)
84NAME_SOURCES = (
85 ("", _("Not Set")),
86 ("A", _("From Customer")),
87 ("K", _("From Bank Clerk")),
88 ("J", _("From Bank System")),
89)
91CORRECTION_IDENTIFIER = (
92 ("0", _("Regular Entry")),
93 ("1", _("Correction Entry")),
94)
96DELIVERY_METHOD_UNKNOWN = ""
97DELIVERY_FROM_CUSTOMER = "A"
98DELIVERY_FROM_BANK_CLERK = "K"
99DELIVERY_FROM_BANK_SYSTEM = "J"
101DELIVERY_METHOD = (
102 (DELIVERY_METHOD_UNKNOWN, ""),
103 (DELIVERY_FROM_CUSTOMER, _("From Customer")),
104 (DELIVERY_FROM_BANK_CLERK, _("From Bank Clerk")),
105 (DELIVERY_FROM_BANK_SYSTEM, _("From Bank System")),
106)
108PAYOUT_ON_HOLD = "H"
109PAYOUT_WAITING_PROCESSING = "W"
110PAYOUT_WAITING_UPLOAD = "U"
111PAYOUT_UPLOADED = "D"
112PAYOUT_PAID = "P"
113PAYOUT_CANCELED = "C"
114PAYOUT_ERROR = "E"
116PAYOUT_STATE = (
117 (PAYOUT_ON_HOLD, _("on hold")),
118 (PAYOUT_WAITING_PROCESSING, _("waiting processing")),
119 (PAYOUT_WAITING_UPLOAD, _("waiting upload")),
120 (PAYOUT_UPLOADED, _("uploaded")),
121 (PAYOUT_PAID, _("paid")),
122 (PAYOUT_CANCELED, _("canceled")),
123 (PAYOUT_ERROR, _("error")),
124)
127class Statement(AccountEntrySourceFile):
128 file = models.ForeignKey("StatementFile", blank=True, default=None, null=True, on_delete=models.CASCADE)
129 account = models.ForeignKey(Account, related_name="+", on_delete=models.PROTECT)
130 account_number = SafeCharField(_("account number"), max_length=32, db_index=True)
131 statement_identifier = SafeCharField(_("statement identifier"), max_length=48, db_index=True, blank=True, default="")
132 statement_number = models.SmallIntegerField(_("statement number"), db_index=True)
133 begin_date = models.DateField(_("begin date"), db_index=True)
134 end_date = models.DateField(_("end date"), db_index=True)
135 record_date = models.DateTimeField(_("record date"), db_index=True)
136 customer_identifier = SafeCharField(_("customer identifier"), max_length=64, blank=True, default="")
137 begin_balance_date = models.DateField(_("begin balance date"), null=True, blank=True, default=None)
138 begin_balance = models.DecimalField(_("begin balance"), max_digits=10, decimal_places=2)
139 record_count = models.IntegerField(_("record count"), null=True, default=None)
140 currency_code = SafeCharField(_("currency code"), max_length=3)
141 account_name = SafeCharField(_("account name"), max_length=32, blank=True, default="")
142 account_limit = models.DecimalField(_("account limit"), max_digits=10, decimal_places=2, blank=True, default=None, null=True)
143 owner_name = SafeCharField(_("owner name"), max_length=64)
144 contact_info_1 = SafeCharField(_("contact info (1)"), max_length=64, blank=True, default="")
145 contact_info_2 = SafeCharField(_("contact info (2)"), max_length=64, blank=True, default="")
146 bank_specific_info_1 = SafeCharField(_("bank specific info (1)"), max_length=1024, blank=True, default="")
147 iban = SafeCharField(_("IBAN"), max_length=32, db_index=True)
148 bic = SafeCharField(_("BIC"), max_length=11, db_index=True)
150 class Meta:
151 verbose_name = _("statement")
152 verbose_name_plural = _("statements")
155class PaymentRecordManager(AccountEntryManager):
156 def filter_matched(self):
157 return self.exclude(child_set=None)
159 def filter_unmatched(self):
160 return self.filter(child_set=None)
163class StatementRecord(AccountEntry):
164 objects: models.Manager = PaymentRecordManager() # type: ignore
165 statement = models.ForeignKey(Statement, verbose_name=_("statement"), related_name="record_set", on_delete=models.CASCADE)
166 line_number = models.SmallIntegerField(_("line number"), default=None, null=True, blank=True)
167 record_number = models.IntegerField(_("record number"), default=None, null=True, blank=True)
168 archive_identifier = SafeCharField(_("archive identifier"), max_length=64, blank=True, default="", db_index=True)
169 record_date = models.DateField(_("record date"), db_index=True)
170 value_date = models.DateField(_("value date"), db_index=True, blank=True, null=True, default=None)
171 paid_date = models.DateField(_("paid date"), db_index=True, blank=True, null=True, default=None)
172 entry_type = SafeCharField(_("entry type"), max_length=1, choices=RECORD_ENTRY_TYPE, db_index=True)
173 record_code = SafeCharField(_("record type"), max_length=4, choices=RECORD_CODES, db_index=True, blank=True)
174 record_domain = SafeCharField(_("record domain"), max_length=4, choices=RECORD_DOMAIN, db_index=True, blank=True)
175 family_code = SafeCharField(_("family code"), max_length=4, db_index=True, blank=True, default="")
176 sub_family_code = SafeCharField(_("sub family code"), max_length=4, db_index=True, blank=True, default="")
177 record_description = SafeCharField(_("record description"), max_length=128, blank=True, default="")
178 receipt_code = SafeCharField(_("receipt code"), max_length=1, choices=RECEIPT_CODE, db_index=True, blank=True)
179 delivery_method = SafeCharField(_("delivery method"), max_length=1, db_index=True, choices=DELIVERY_METHOD, blank=True)
180 name = SafeCharField(_("name"), max_length=128, blank=True, db_index=True)
181 name_source = SafeCharField(_("name source"), max_length=1, blank=True, choices=NAME_SOURCES)
182 recipient_account_number = SafeCharField(_("recipient account number"), max_length=32, blank=True, db_index=True)
183 recipient_account_number_changed = SafeCharField(_("recipient account number changed"), max_length=1, blank=True)
184 remittance_info = SafeCharField(_("remittance info"), max_length=35, db_index=True, blank=True)
185 messages = SafeTextField(_("messages"), blank=True, default="")
186 client_messages = SafeTextField(_("client messages"), blank=True, default="")
187 bank_messages = SafeTextField(_("bank messages"), blank=True, default="")
188 manually_settled = models.BooleanField(_("manually settled"), db_index=True, default=False, blank=True)
190 class Meta:
191 verbose_name = _("statement record")
192 verbose_name_plural = _("statement records")
194 @property
195 def is_settled(self) -> bool:
196 """
197 True if entry is either manually settled or has SUM(children)==amount.
198 """
199 return self.manually_settled or sum_queryset(self.child_set) == self.amount # type: ignore
201 def clean(self):
202 self.source_file = self.statement
203 self.timestamp = pytz.utc.localize(datetime.combine(self.record_date, time(0, 0)))
204 if self.name:
205 self.description = "{name}: {record_description}".format(record_description=self.record_description, name=self.name)
206 else:
207 self.description = "{record_description}".format(record_description=self.record_description)
210class CurrencyExchangeSource(models.Model):
211 name = SafeCharField(_("name"), max_length=64)
212 created = models.DateTimeField(_("created"), default=now, db_index=True, blank=True, editable=False)
214 class Meta:
215 verbose_name = _("currency exchange source")
216 verbose_name_plural = _("currency exchange sources")
218 def __str__(self):
219 return str(self.name)
222class CurrencyExchange(models.Model):
223 record_date = models.DateField(_("record date"), db_index=True)
224 source_currency = SafeCharField(_("source currency"), max_length=3, blank=True)
225 target_currency = SafeCharField(_("target currency"), max_length=3, blank=True)
226 unit_currency = SafeCharField(_("unit currency"), max_length=3, blank=True)
227 exchange_rate = models.DecimalField(_("exchange rate"), decimal_places=6, max_digits=12, null=True, default=None, blank=True)
228 source = models.ForeignKey(
229 CurrencyExchangeSource,
230 verbose_name=_("currency exchange source"),
231 blank=True,
232 null=True,
233 default=None,
234 on_delete=models.PROTECT,
235 ) # noqa
237 class Meta:
238 verbose_name = _("currency exchange")
239 verbose_name_plural = _("currency exchanges")
241 def __str__(self):
242 return "{src} = {rate} {tgt}".format(src=self.source_currency, tgt=self.target_currency, rate=self.exchange_rate)
245class StatementRecordDetail(models.Model):
246 record = models.ForeignKey(StatementRecord, verbose_name=_("record"), related_name="detail_set", on_delete=models.CASCADE)
247 batch_identifier = SafeCharField(_("batch message id"), max_length=64, db_index=True, blank=True, default="")
248 amount = models.DecimalField(verbose_name=_("amount"), max_digits=10, decimal_places=2, blank=True, default=None, null=True, db_index=True)
249 currency_code = SafeCharField(_("currency code"), max_length=3)
250 instructed_amount = models.DecimalField(
251 verbose_name=_("instructed amount"),
252 max_digits=10,
253 decimal_places=2,
254 blank=True,
255 default=None,
256 null=True,
257 db_index=True,
258 )
259 exchange = models.ForeignKey(
260 CurrencyExchange,
261 verbose_name=_("currency exchange"),
262 related_name="recorddetail_set",
263 on_delete=models.PROTECT,
264 null=True,
265 default=None,
266 blank=True,
267 )
268 archive_identifier = SafeCharField(_("archive identifier"), max_length=64, blank=True)
269 end_to_end_identifier = SafeCharField(_("end-to-end identifier"), max_length=64, blank=True)
270 creditor_name = SafeCharField(_("creditor name"), max_length=128, blank=True)
271 creditor_account = SafeCharField(_("creditor account"), max_length=35, blank=True)
272 creditor_account_scheme = SafeCharField(_("creditor account scheme"), max_length=8, blank=True)
273 debtor_name = SafeCharField(_("debtor name"), max_length=128, blank=True)
274 ultimate_debtor_name = SafeCharField(_("ultimate debtor name"), max_length=128, blank=True)
275 unstructured_remittance_info = SafeCharField(_("unstructured remittance info"), max_length=2048, blank=True)
276 paid_date = models.DateTimeField(_("paid date"), db_index=True, blank=True, null=True, default=None)
278 class Meta:
279 verbose_name = _("statement record details")
280 verbose_name_plural = _("statement record details")
283class StatementRecordRemittanceInfo(models.Model):
284 detail = models.ForeignKey(StatementRecordDetail, related_name="remittanceinfo_set", on_delete=models.CASCADE)
285 additional_info = SafeCharField(_("additional remittance info"), max_length=256, blank=True, db_index=True)
286 amount = models.DecimalField(_("amount"), decimal_places=2, max_digits=10, null=True, default=None, blank=True)
287 currency_code = SafeCharField(_("currency code"), max_length=3, blank=True)
288 reference = SafeCharField(_("reference"), max_length=35, blank=True, db_index=True)
290 def __str__(self):
291 return "{} {} ref {} ({})".format(self.amount if self.amount is not None else "", self.currency_code, self.reference, self.additional_info)
293 class Meta:
294 verbose_name = _("statement record remittance info")
295 verbose_name_plural = _("statement record remittance info")
298class StatementRecordSepaInfo(models.Model):
299 record = models.OneToOneField(StatementRecord, verbose_name=_("record"), related_name="sepa_info", on_delete=models.CASCADE)
300 reference = SafeCharField(_("reference"), max_length=35, blank=True)
301 iban_account_number = SafeCharField(_("IBAN"), max_length=35, blank=True)
302 bic_code = SafeCharField(_("BIC"), max_length=35, blank=True)
303 recipient_name_detail = SafeCharField(_("recipient name detail"), max_length=70, blank=True)
304 payer_name_detail = SafeCharField(_("payer name detail"), max_length=70, blank=True)
305 identifier = SafeCharField(_("identifier"), max_length=35, blank=True)
306 archive_identifier = SafeCharField(_("archive identifier"), max_length=64, blank=True)
308 class Meta:
309 verbose_name = _("SEPA")
310 verbose_name_plural = _("SEPA")
312 def __str__(self):
313 return "[{}]".format(self.id)
316class ReferencePaymentBatchManager(models.Manager):
317 def latest_record_date(self) -> Optional[datetime]:
318 """
319 :return: datetime of latest record available or None
320 """
321 obj = self.order_by("-record_date").first()
322 if not obj:
323 return None
324 return obj.record_date
327class ReferencePaymentBatch(AccountEntrySourceFile):
328 objects = ReferencePaymentBatchManager()
329 file = models.ForeignKey("ReferencePaymentBatchFile", blank=True, default=None, null=True, on_delete=models.CASCADE)
330 record_date = models.DateTimeField(_("record date"), db_index=True)
331 identifier = SafeCharField(_("institution"), max_length=32, blank=True)
332 institution_identifier = SafeCharField(_("institution"), max_length=2, blank=True, default="")
333 service_identifier = SafeCharField(_("service"), max_length=9, blank=True, default="")
334 currency_identifier = SafeCharField(_("currency"), max_length=3, blank=True, default="EUR")
335 cached_total_amount = models.DecimalField(_("total amount"), max_digits=10, decimal_places=2, null=True, default=None, blank=True)
337 class Meta:
338 verbose_name = _("reference payment batch")
339 verbose_name_plural = _("reference payment batches")
341 def get_total_amount(self, force: bool = False) -> Decimal:
342 if self.cached_total_amount is None or force:
343 self.cached_total_amount = sum_queryset(ReferencePaymentRecord.objects.filter(batch=self))
344 self.save(update_fields=["cached_total_amount"])
345 return self.cached_total_amount
347 @property
348 def total_amount(self) -> Decimal:
349 return self.get_total_amount()
351 total_amount.fget.short_description = _("total amount") # type: ignore
354class ReferencePaymentRecord(AccountEntry):
355 """
356 Reference payment record. See jacc.Invoice for date/time variable naming conventions.
357 """
359 objects = PaymentRecordManager() # type: ignore
360 batch = models.ForeignKey(ReferencePaymentBatch, verbose_name=_("batch"), related_name="record_set", on_delete=models.CASCADE)
361 line_number = models.SmallIntegerField(_("line number"), default=0, blank=True)
362 record_type = SafeCharField(_("record type"), max_length=4, blank=True, default="")
363 account_number = SafeCharField(_("account number"), max_length=32, db_index=True)
364 record_date = models.DateField(_("record date"), db_index=True)
365 paid_date = models.DateField(_("paid date"), db_index=True, blank=True, null=True, default=None)
366 value_date = models.DateField(_("value date"), db_index=True, blank=True, null=True, default=None)
367 archive_identifier = SafeCharField(_("archive identifier"), max_length=32, blank=True, default="", db_index=True)
368 remittance_info = SafeCharField(_("remittance info"), max_length=256, db_index=True)
369 payer_name = SafeCharField(_("payer name"), max_length=64, blank=True, default="", db_index=True)
370 currency_identifier = SafeCharField(_("currency identifier"), max_length=1, choices=CURRENCY_IDENTIFIERS, blank=True, default="")
371 name_source = SafeCharField(_("name source"), max_length=1, choices=NAME_SOURCES, blank=True, default="")
372 correction_identifier = SafeCharField(_("correction identifier"), max_length=1, choices=CORRECTION_IDENTIFIER, default="")
373 delivery_method = SafeCharField(_("delivery method"), max_length=1, db_index=True, choices=DELIVERY_METHOD, blank=True, default="")
374 receipt_code = SafeCharField(_("receipt code"), max_length=1, choices=RECEIPT_CODE, db_index=True, blank=True, default="")
375 manually_settled = models.BooleanField(_("manually settled"), db_index=True, default=False, blank=True)
376 instructed_amount = models.DecimalField(_("instructed amount"), blank=True, default=None, null=True, max_digits=10, decimal_places=2)
377 instructed_currency = SafeCharField(_("instructed currency"), blank=True, default="", max_length=3)
378 creditor_bank_bic = SafeCharField(_("creditor bank BIC"), max_length=16, blank=True, default="")
379 end_to_end_identifier = SafeCharField(_("end to end identifier"), max_length=128, blank=True, default="")
381 class Meta:
382 verbose_name = _("reference payment records")
383 verbose_name_plural = _("reference payment records")
385 @property
386 def is_settled(self) -> bool:
387 """
388 True if entry is either manually settled or has SUM(children)==amount.
389 """
390 return self.manually_settled or sum_queryset(self.child_set) == self.amount # type: ignore
392 @property
393 def remittance_info_short(self) -> str:
394 """
395 Remittance info without preceding zeroes.
396 :return: str
397 """
398 return re.sub(r"^0+", "", self.remittance_info)
400 def clean(self):
401 self.source_file = self.batch
402 self.timestamp = pytz.utc.localize(datetime.combine(self.paid_date or self.record_date, time(0, 0)))
403 self.description = "{amount} {remittance_info} {payer_name}".format(
404 amount=self.amount, remittance_info=self.remittance_info, payer_name=self.payer_name
405 )
408class StatementFile(models.Model):
409 created = models.DateTimeField(_("created"), default=now, db_index=True, blank=True, editable=False)
410 file = models.FileField(verbose_name=_("file"), upload_to="uploads")
411 original_filename = SafeCharField(_("original filename"), blank=True, default="", max_length=256)
412 tag = SafeCharField(_("tag"), blank=True, max_length=64, default="", db_index=True)
413 errors = SafeTextField(_("errors"), max_length=4086, default="", blank=True)
415 class Meta:
416 verbose_name = _("account statement file")
417 verbose_name_plural = _("account statement files")
419 @property
420 def full_path(self):
421 return join(settings.MEDIA_ROOT, self.file.name) if self.file else ""
423 def __str__(self):
424 return basename(str(self.file.name)) if self.file else ""
427class ReferencePaymentBatchFile(models.Model):
428 created = models.DateTimeField(_("created"), default=now, db_index=True, blank=True, editable=False)
429 file = models.FileField(verbose_name=_("file"), upload_to="uploads")
430 original_filename = SafeCharField(_("original filename"), blank=True, default="", max_length=256)
431 tag = SafeCharField(_("tag"), blank=True, max_length=64, default="", db_index=True)
432 timestamp = models.DateTimeField(_("timestamp"), default=None, null=True, db_index=True, blank=True, editable=False)
433 msg_id = models.CharField(_("message identifier"), max_length=32, default="", blank=True, db_index=True)
434 additional_info = models.CharField(_("additional information"), max_length=128, default="", blank=True)
435 errors = SafeTextField(_("errors"), max_length=4086, default="", blank=True)
436 cached_total_amount = models.DecimalField(_("total amount"), max_digits=10, decimal_places=2, null=True, default=None, blank=True)
438 class Meta:
439 verbose_name = _("reference payment batch file")
440 verbose_name_plural = _("reference payment batch files")
442 def clean(self):
443 if self.timestamp is None:
444 self.timestamp = self.created
446 def get_total_amount(self, force: bool = False) -> Decimal:
447 if self.cached_total_amount is None or force:
448 self.cached_total_amount = sum_queryset(ReferencePaymentRecord.objects.filter(batch__file=self))
449 self.save(update_fields=["cached_total_amount"])
450 return self.cached_total_amount
452 @property
453 def total_amount(self) -> Decimal:
454 return self.get_total_amount()
456 total_amount.fget.short_description = _("total amount") # type: ignore
458 @property
459 def full_path(self):
460 return join(settings.MEDIA_ROOT, self.file.name) if self.file else ""
462 def __str__(self):
463 return basename(str(self.file.name)) if self.file else ""
466class PayoutParty(models.Model):
467 name = SafeCharField(_("name"), max_length=128, db_index=True)
468 account_number = SafeCharField(_("account number"), max_length=35, db_index=True, validators=[iban_validator])
469 bic = SafeCharField(_("BIC"), max_length=16, db_index=True, blank=True)
470 org_id = SafeCharField(_("organization id"), max_length=32, db_index=True, blank=True, default="")
471 address = SafeTextField(_("address"), blank=True, default="")
472 country_code = SafeCharField(_("country code"), max_length=2, default="FI", blank=True, db_index=True)
473 payouts_account = models.ForeignKey(Account, verbose_name=_("payouts account"), null=True, default=None, blank=True, on_delete=models.PROTECT)
475 class Meta:
476 verbose_name = _("payout party")
477 verbose_name_plural = _("payout parties")
479 def __str__(self):
480 return "{} ({})".format(self.name, self.account_number)
482 @property
483 def is_payout_party_used(self) -> bool:
484 """
485 True if payout party has been used in any payment.
486 """
487 if not hasattr(self, "id") or self.id is None:
488 return False
489 return Payout.objects.all().filter(Q(recipient=self) | Q(payer=self)).exists()
491 @property
492 def is_account_number_changed(self) -> bool:
493 """
494 True if account number has been changed compared to the one stored in DB.
495 """
496 if not hasattr(self, "id") or self.id is None:
497 return False
498 return PayoutParty.objects.all().filter(id=self.id).exclude(account_number=self.account_number).exists()
500 def clean(self):
501 if not self.bic:
502 self.bic = iban_bic(self.account_number)
503 if self.is_account_number_changed and self.is_payout_party_used:
504 raise ValidationError({"account_number": _("Account number changes of used payout parties is not allowed. Create a new payout party instead.")})
506 @property
507 def address_lines(self):
508 out = []
509 for line in self.address.split("\n"):
510 line = line.strip()
511 if line:
512 out.append(line)
513 return out
516class Payout(AccountEntry):
517 connection = models.ForeignKey(
518 "WsEdiConnection",
519 verbose_name=_("WS-EDI connection"),
520 on_delete=models.SET_NULL,
521 null=True,
522 blank=True,
523 related_name="+",
524 )
525 payer = models.ForeignKey(PayoutParty, verbose_name=_("payer"), related_name="+", on_delete=models.PROTECT)
526 recipient = models.ForeignKey(PayoutParty, verbose_name=_("recipient"), related_name="+", on_delete=models.PROTECT)
527 messages = SafeTextField(_("recipient messages"), blank=True, default="")
528 reference = SafeCharField(_("recipient reference"), blank=True, default="", max_length=32)
529 msg_id = SafeCharField(_("message id"), max_length=64, blank=True, db_index=True, editable=False)
530 file_name = SafeCharField(_("file name"), max_length=255, blank=True, db_index=True, editable=False)
531 full_path = SafeTextField(_("full path"), blank=True, editable=False)
532 file_reference = SafeCharField(_("file reference"), max_length=255, blank=True, db_index=True, editable=False)
533 due_date = models.DateField(_("due date"), db_index=True, blank=True, null=True, default=None)
534 paid_date = models.DateTimeField(_("paid date"), db_index=True, blank=True, null=True, default=None)
535 state = SafeCharField(_("state"), max_length=1, blank=True, default=PAYOUT_WAITING_PROCESSING, choices=PAYOUT_STATE, db_index=True)
537 class Meta:
538 verbose_name = _("payout")
539 verbose_name_plural = _("payouts")
541 def clean(self):
542 if self.parent and not self.amount:
543 self.amount = self.parent.amount
545 # prevent defining both reference and messages
546 if self.messages and self.reference or not self.messages and not self.reference:
547 raise ValidationError(_("payment.must.have.reference.or.messages"))
549 # validate reference if any
550 if self.reference:
551 if self.reference[:2] == "RF": # noqa
552 iso_payment_reference_validator(self.reference)
553 else:
554 fi_payment_reference_validator(self.reference)
556 # prevent canceling payouts which have been uploaded successfully
557 if self.state == PAYOUT_CANCELED:
558 if self.is_upload_done:
559 group_status = self.group_status
560 if group_status != "RJCT":
561 raise ValidationError(_("File already uploaded") + " ({})".format(group_status))
563 # save paid time if marking payout as paid manually
564 if self.state == PAYOUT_PAID and not self.paid_date:
565 self.paid_date = now()
566 status = self.payoutstatus_set.order_by("-created").first()
567 if status:
568 assert isinstance(status, PayoutStatus)
569 self.paid_date = status.created
571 # always require amount
572 if self.amount is None or self.amount <= Decimal("0.00"):
573 raise ValidationError({"amount": _("value > 0 required")})
575 def generate_msg_id(self, commit: bool = True):
576 msg_id_base = re.sub(r"[^\d]", "", now().isoformat())[:-4]
577 self.msg_id = msg_id_base + "P" + str(self.id)
578 if commit:
579 self.save(update_fields=["msg_id"])
581 @property
582 def state_name(self):
583 return choices_label(PAYOUT_STATE, self.state)
585 @property
586 def is_upload_done(self):
587 return PayoutStatus.objects.filter(payout=self, response_code="00").first() is not None
589 @property
590 def is_accepted(self):
591 return self.has_group_status("ACCP")
593 @property
594 def is_rejected(self):
595 return self.has_group_status("RJCT")
597 def has_group_status(self, group_status: str) -> bool:
598 return PayoutStatus.objects.filter(payout=self, group_status=group_status).exists()
600 @property
601 def group_status(self):
602 status = PayoutStatus.objects.filter(payout=self).order_by("-timestamp", "-id").first()
603 return status.group_status if status else ""
605 group_status.fget.short_description = _("payment.group.status") # type: ignore # pytype: disable=attribute-error
608class PayoutStatusManager(models.Manager):
609 def is_file_processed(self, filename: str) -> bool:
610 return self.filter(file_name=basename(filename)).first() is not None
613class PayoutStatus(models.Model):
614 objects = PayoutStatusManager()
615 payout = models.ForeignKey(
616 Payout,
617 verbose_name=_("payout"),
618 related_name="payoutstatus_set",
619 on_delete=models.PROTECT,
620 null=True,
621 default=None,
622 blank=True,
623 )
624 created = models.DateTimeField(_("created"), default=now, db_index=True, editable=False, blank=True)
625 timestamp = models.DateTimeField(_("timestamp"), default=now, db_index=True, editable=False, blank=True)
626 file_name = SafeCharField(_("file name"), max_length=128, blank=True, db_index=True, editable=False)
627 file_path = SafeCharField(_("file path"), max_length=255, blank=True, db_index=True, editable=False)
628 response_code = SafeCharField(_("response code"), max_length=4, blank=True, db_index=True)
629 response_text = SafeCharField(_("response text"), max_length=128, blank=True)
630 msg_id = SafeCharField(_("message id"), max_length=64, blank=True, db_index=True)
631 original_msg_id = SafeCharField(_("original message id"), blank=True, max_length=64, db_index=True)
632 group_status = SafeCharField(_("group status"), max_length=8, blank=True, db_index=True)
633 status_reason = SafeCharField(_("status reason"), max_length=255, blank=True)
635 class Meta:
636 verbose_name = _("payout status")
637 verbose_name_plural = _("payout statuses")
639 def __str__(self):
640 return str(self.group_status)
642 @property
643 def full_path(self) -> str:
644 return get_media_full_path(self.file_path) if self.file_path else ""
646 @property
647 def is_accepted(self):
648 return self.group_status == "ACCP"
650 @property
651 def is_rejected(self):
652 return self.group_status == "RJCT"
655class Refund(Payout):
656 class Meta:
657 verbose_name = _("incoming.payment.refund")
658 verbose_name_plural = _("incoming.payment.refunds")
660 attachment = models.FileField(verbose_name=_("attachment"), blank=True, upload_to="uploads")
663class WsEdiSoapCall(models.Model):
664 connection = models.ForeignKey("WsEdiConnection", verbose_name=_("WS-EDI connection"), on_delete=models.CASCADE)
665 command = SafeCharField(_("command"), max_length=64, blank=True, db_index=True)
666 created = models.DateTimeField(_("created"), default=now, db_index=True, editable=False, blank=True)
667 executed = models.DateTimeField(_("executed"), default=None, null=True, db_index=True, editable=False, blank=True)
668 error = SafeTextField(_("error"), blank=True)
670 class Meta:
671 verbose_name = _("WS-EDI SOAP call")
672 verbose_name_plural = _("WS-EDI SOAP calls")
674 def __str__(self):
675 return "WsEdiSoapCall({})".format(self.id)
677 @property
678 def timestamp(self) -> datetime:
679 return self.created.astimezone(pytz.timezone("Europe/Helsinki"))
681 @property
682 def timestamp_digits(self) -> str:
683 v = re.sub(r"[^\d]", "", self.created.isoformat())
684 return v[:17]
686 @property
687 def request_identifier(self) -> str:
688 return str(self.id)
690 @property
691 def command_camelcase(self) -> str:
692 return self.command[0:1].lower() + self.command[1:] # noqa
694 def debug_get_filename(self, file_type: str) -> str:
695 return "{:08}{}.xml".format(self.id, file_type)
697 @property
698 def debug_request_full_path(self) -> str:
699 return self.debug_get_file_path(self.debug_get_filename("q"))
701 @property
702 def debug_response_full_path(self) -> str:
703 return self.debug_get_file_path(self.debug_get_filename("s"))
705 @staticmethod
706 def debug_get_file_path(filename: str) -> str:
707 return os.path.join(settings.WSEDI_LOG_PATH, filename) if hasattr(settings, "WSEDI_LOG_PATH") and settings.WSEDI_LOG_PATH else ""
710class WsEdiConnectionManager(models.Manager):
711 def get_by_receiver_identifier(self, receiver_identifier: str):
712 objs = list(self.filter(receiver_identifier=receiver_identifier))
713 if len(objs) != 1:
714 raise ValidationError(
715 _("WS-EDI connection cannot be found by receiver identifier {receiver_identifier} since there are {matches} matches").format(
716 receiver_identifier=receiver_identifier, matches=len(objs)
717 )
718 )
719 return objs[0]
722class WsEdiConnection(models.Model):
723 objects = WsEdiConnectionManager()
724 name = SafeCharField(_("name"), max_length=64)
725 enabled = models.BooleanField(_("enabled"), blank=True, default=True)
726 sender_identifier = SafeCharField(_("sender identifier"), max_length=32)
727 receiver_identifier = SafeCharField(_("receiver identifier"), max_length=32)
728 target_identifier = SafeCharField(_("target identifier"), max_length=32)
729 environment = SafeCharField(_("environment"), max_length=32, default="PRODUCTION")
730 pin = SafeCharField("PIN", max_length=64, default="", blank=True)
731 pki_endpoint = models.URLField(_("PKI endpoint"), blank=True, default="")
732 bank_root_cert_file = models.FileField(verbose_name=_("bank root certificate file"), blank=True, upload_to="certs")
733 soap_endpoint = models.URLField(_("EDI endpoint"))
734 signing_cert_file = models.FileField(verbose_name=_("signing certificate file"), blank=True, upload_to="certs")
735 signing_key_file = models.FileField(verbose_name=_("signing key file"), blank=True, upload_to="certs")
736 encryption_cert_file = models.FileField(verbose_name=_("encryption certificate file"), blank=True, upload_to="certs")
737 encryption_key_file = models.FileField(verbose_name=_("encryption key file"), blank=True, upload_to="certs")
738 bank_encryption_cert_file = models.FileField(verbose_name=_("bank encryption cert file"), blank=True, upload_to="certs")
739 bank_signing_cert_file = models.FileField(verbose_name=_("bank signing cert file"), blank=True, upload_to="certs")
740 ca_cert_file = models.FileField(verbose_name=_("CA certificate file"), blank=True, upload_to="certs")
741 debug_commands = SafeTextField(_("debug commands"), blank=True, help_text=_("wsedi.connection.debug.commands.help.text"))
742 created = models.DateTimeField(_("created"), default=now, db_index=True, editable=False, blank=True)
743 _signing_cert = None
745 class Meta:
746 verbose_name = _("WS-EDI connection")
747 verbose_name_plural = _("WS-EDI connections")
749 def __str__(self):
750 return "{} / {}".format(self.name, self.receiver_identifier)
752 @property
753 def is_test(self) -> bool:
754 return str(self.environment).lower() in ["customertest", "test"]
756 @property
757 def signing_cert_full_path(self) -> str:
758 return get_media_full_path(self.signing_cert_file.file.name) if self.signing_cert_file else ""
760 @property
761 def signing_key_full_path(self) -> str:
762 return get_media_full_path(self.signing_key_file.file.name) if self.signing_key_file else ""
764 @property
765 def encryption_cert_full_path(self) -> str:
766 return get_media_full_path(self.encryption_cert_file.file.name) if self.encryption_cert_file else ""
768 @property
769 def encryption_key_full_path(self) -> str:
770 return get_media_full_path(self.encryption_key_file.file.name) if self.encryption_key_file else ""
772 @property
773 def bank_encryption_cert_full_path(self) -> str:
774 return get_media_full_path(self.bank_encryption_cert_file.file.name) if self.bank_encryption_cert_file else ""
776 @property
777 def bank_root_cert_full_path(self) -> str:
778 return get_media_full_path(self.bank_root_cert_file.file.name) if self.bank_root_cert_file else ""
780 @property
781 def ca_cert_full_path(self) -> str:
782 return get_media_full_path(self.ca_cert_file.file.name) if self.ca_cert_file else ""
784 @property
785 def signing_cert_with_public_key_full_path(self) -> str:
786 src_file = self.signing_cert_full_path
787 file = src_file[:-4] + "-with-pubkey.pem"
788 if not os.path.isfile(file):
789 cmd = [
790 settings.OPENSSL_PATH,
791 "x509",
792 "-pubkey",
793 "-in",
794 src_file,
795 ]
796 logger.info(" ".join(cmd))
797 out = subprocess.check_output(cmd)
798 with open(file, "wb") as fp:
799 fp.write(out)
800 return file
802 @property
803 def bank_encryption_cert_with_public_key_full_path(self) -> str:
804 src_file = self.bank_encryption_cert_full_path
805 file = src_file[:-4] + "-with-pubkey.pem"
806 if not os.path.isfile(file):
807 cmd = [
808 settings.OPENSSL_PATH,
809 "x509",
810 "-pubkey",
811 "-in",
812 src_file,
813 ]
814 # logger.info(' '.join(cmd))
815 out = subprocess.check_output(cmd)
816 with open(file, "wb") as fp:
817 fp.write(out)
818 return file
820 @property
821 def signing_cert(self):
822 if hasattr(self, "_signing_cert") and self._signing_cert:
823 return self._signing_cert
824 self._signing_cert = get_x509_cert_from_file(self.signing_cert_full_path)
825 return self._signing_cert
827 def get_pki_template(self, template_name: str, soap_call: WsEdiSoapCall, **kwargs) -> bytes:
828 return format_xml(
829 get_template(template_name).render(
830 {
831 "ws": soap_call.connection,
832 "soap_call": soap_call,
833 "command": soap_call.command,
834 "timestamp": now().astimezone(pytz.timezone("Europe/Helsinki")).isoformat(),
835 **kwargs,
836 }
837 )
838 ).encode()
840 def get_application_request(self, command: str, **kwargs) -> bytes:
841 return format_xml(
842 get_template("jbank/application_request_template.xml").render(
843 {
844 "ws": self,
845 "command": command,
846 "timestamp": now().astimezone(pytz.timezone("Europe/Helsinki")).isoformat(),
847 **kwargs,
848 }
849 )
850 ).encode()
852 @classmethod
853 def verify_signature(cls, content: bytes, signing_key_full_path: str):
854 with tempfile.NamedTemporaryFile() as fp:
855 fp.write(content)
856 fp.flush()
857 cmd = [settings.XMLSEC1_PATH, "--verify", "--pubkey-pem", signing_key_full_path, fp.name]
858 # logger.info(' '.join(cmd))
859 subprocess.check_output(cmd)
861 def sign_pki_request(self, content: bytes, signing_key_full_path: str, signing_cert_full_path: str) -> bytes:
862 return self._sign_request(content, signing_key_full_path, signing_cert_full_path)
864 def sign_application_request(self, content: bytes) -> bytes:
865 return self._sign_request(content, self.signing_key_full_path, self.signing_cert_full_path)
867 @classmethod
868 def _sign_request(cls, content: bytes, signing_key_full_path: str, signing_cert_full_path: str) -> bytes:
869 """
870 Sign a request.
871 See https://users.dcc.uchile.cl/~pcamacho/tutorial/web/xmlsec/xmlsec.html
872 :param content: XML application request
873 :param signing_key_full_path: Override signing key full path (if not use self.signing_key_full_path)
874 :param signing_cert_full_path: Override signing key full path (if not use self.signing_cert_full_path)
875 :return: str
876 """
877 with tempfile.NamedTemporaryFile() as fp:
878 fp.write(content)
879 fp.flush()
880 cmd = [
881 settings.XMLSEC1_PATH,
882 "--sign",
883 "--privkey-pem",
884 "{},{}".format(signing_key_full_path, signing_cert_full_path),
885 fp.name,
886 ]
887 # logger.info(' '.join(cmd))
888 out = subprocess.check_output(cmd)
889 cls.verify_signature(out, signing_key_full_path)
890 return out
892 def encrypt_pki_request(self, content: bytes) -> bytes:
893 return self._encrypt_request(content)
895 def encrypt_application_request(self, content: bytes) -> bytes:
896 return self._encrypt_request(content)
898 def _encrypt_request(self, content: bytes) -> bytes:
899 with tempfile.NamedTemporaryFile() as fp:
900 fp.write(content)
901 fp.flush()
902 cmd = [
903 self._xmlsec1_example_bin("encrypt3"),
904 fp.name,
905 self.bank_encryption_cert_with_public_key_full_path,
906 self.bank_encryption_cert_full_path,
907 ]
908 # logger.info(' '.join(cmd))
909 out = subprocess.check_output(cmd)
910 return out
912 def encode_application_request(self, content: bytes) -> bytes:
913 lines = content.split(b"\n")
914 if lines and lines[0].startswith(b"<?xml"):
915 lines = lines[1:]
916 content_without_xml_tag = b"\n".join(lines)
917 return base64.b64encode(content_without_xml_tag)
919 def decode_application_response(self, content: bytes) -> bytes:
920 return base64.b64decode(content)
922 def decrypt_application_response(self, content: bytes) -> bytes:
923 with tempfile.NamedTemporaryFile() as fp:
924 fp.write(content)
925 fp.flush()
926 cmd = [
927 self._xmlsec1_example_bin("decrypt3"),
928 fp.name,
929 self.encryption_key_full_path,
930 ]
931 # logger.info(' '.join(cmd))
932 out = subprocess.check_output(cmd)
933 return out
935 @property
936 def debug_command_list(self) -> List[str]:
937 return [x for x in re.sub(r"[^\w]+", " ", self.debug_commands).strip().split(" ") if x]
939 @staticmethod
940 def _xmlsec1_example_bin(file: str) -> str:
941 if hasattr(settings, "XMLSEC1_EXAMPLES_PATH") and settings.XMLSEC1_EXAMPLES_PATH:
942 xmlsec1_examples_path = settings.XMLSEC1_EXAMPLES_PATH
943 else:
944 xmlsec1_examples_path = os.path.join(str(os.getenv("HOME") or ""), "bin/xmlsec1-examples")
945 return str(os.path.join(xmlsec1_examples_path, file))
948class EuriborRateManager(models.Manager):
949 def save_unique(self, record_date: date, name: str, rate: Decimal):
950 return self.get_or_create(record_date=record_date, name=name, defaults={"rate": rate})[0]
953class EuriborRate(models.Model):
954 objects = EuriborRateManager()
955 record_date = models.DateField(_("record date"), db_index=True)
956 name = SafeCharField(_("interest rate name"), db_index=True, max_length=64)
957 rate = models.DecimalField(_("interest rate %"), max_digits=10, decimal_places=4, db_index=True)
958 created = models.DateTimeField(_("created"), default=now, db_index=True, blank=True, editable=False)
960 class Meta:
961 verbose_name = _("euribor rate")
962 verbose_name_plural = _("euribor rates")
965class AccountBalance(models.Model):
966 account_number = models.CharField(_("account number"), max_length=32, db_index=True)
967 bic = models.CharField("BIC", max_length=16, db_index=True)
968 record_datetime = models.DateTimeField(_("record date"), db_index=True)
969 balance = models.DecimalField(_("balance"), max_digits=10, decimal_places=2)
970 available_balance = models.DecimalField(_("available balance"), max_digits=10, decimal_places=2)
971 credit_limit = models.DecimalField(_("credit limit"), max_digits=10, decimal_places=2, null=True, default=None, blank=True)
972 currency = models.CharField(_("currency"), max_length=3, default="EUR", db_index=True)
973 created = models.DateTimeField(_("created"), default=now, db_index=True, blank=True, editable=False)
975 class Meta:
976 verbose_name = _("account balance")
977 verbose_name_plural = _("account balances")