Coverage for jbank/models.py : 79%

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