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.models import AccountEntry, AccountEntrySourceFile, Account, AccountEntryManager
20from jbank.x509_helpers import get_x509_cert_from_file
21from jutil.modelfields import SafeCharField, SafeTextField
22from jutil.format import format_xml, get_media_full_path, choices_label
23from jutil.validators import iban_validator, iban_bic, iso_payment_reference_validator, fi_payment_reference_validator
26logger = logging.getLogger(__name__)
29JBANK_BIN_PATH = Path(__file__).absolute().parent.joinpath('bin')
31RECORD_ENTRY_TYPE = (
32 ('1', _('Deposit')),
33 ('2', _('Withdrawal')),
34 ('3', _('Deposit Correction')),
35 ('4', _('Withdrawal Correction')),
36)
38RECORD_CODES = (
39 ('700', _('Money Transfer (In/Out)')),
40 ('701', _('Recurring Payment (In/Out)')),
41 ('702', _('Bill Payment (Out)')),
42 ('703', _('Payment Terminal Deposit (In)')),
43 ('704', _('Bank Draft (In/Out)')),
44 ('705', _('Reference Payments (In)')),
45 ('706', _('Payment Service (Out)')),
46 ('710', _('Deposit (In)')),
47 ('720', _('Withdrawal (Out)')),
48 ('721', _('Card Payment (Out)')),
49 ('722', _('Check (Out)')),
50 ('730', _('Bank Fees (Out)')),
51 ('740', _('Interests Charged (Out)')),
52 ('750', _('Interests Credited (In)')),
53 ('760', _('Loan (Out)')),
54 ('761', _('Loan Payment (Out)')),
55 ('770', _('Foreign Transfer (In/Out)')),
56 ('780', _('Zero Balancing (In/Out)')),
57 ('781', _('Sweeping (In/Out)')),
58 ('782', _('Topping (In/Out)')),
59)
61RECORD_DOMAIN = (
62 ('PMNT', _('Money Transfer (In/Out)')),
63 ('LDAS', _('Loan Payment (Out)')),
64 ('CAMT', _('Cash Management')),
65 ('ACMT', _('Account Management')),
66 ('XTND', _('Entended Domain')),
67 ('SECU', _('Securities')),
68 ('FORX', _('Foreign Exchange')),
69 ('XTND', _('Entended Domain')),
70 ('NTAV', _('Not Available'))
71)
73RECEIPT_CODE = (
74 ('', ''),
75 ('E', _('Separate')),
76 ('P', _('Separate/Paper')),
77)
79CURRENCY_IDENTIFIERS = (
80 ('1', 'EUR'),
81)
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_FROM_CUSTOMER = 'A'
96DELIVERY_FROM_BANK_CLERK = 'K'
97DELIVERY_FROM_BANK_SYSTEM = 'J'
99DELIVERY_METHOD = (
100 (DELIVERY_FROM_CUSTOMER, _('From Customer')),
101 (DELIVERY_FROM_BANK_CLERK, _('From Bank Clerk')),
102 (DELIVERY_FROM_BANK_SYSTEM, _('From Bank System')),
103)
105PAYOUT_WAITING_PROCESSING = 'W'
106PAYOUT_WAITING_UPLOAD = 'U'
107PAYOUT_UPLOADED = 'D'
108PAYOUT_PAID = 'P'
109PAYOUT_CANCELED = 'C'
110PAYOUT_ERROR = 'E'
112PAYOUT_STATE = (
113 (PAYOUT_WAITING_PROCESSING, _('waiting processing')),
114 (PAYOUT_WAITING_UPLOAD, _('waiting upload')),
115 (PAYOUT_UPLOADED, _('uploaded')),
116 (PAYOUT_PAID, _('paid')),
117 (PAYOUT_CANCELED, _('canceled')),
118 (PAYOUT_ERROR, _('error')),
119)
122class Statement(AccountEntrySourceFile):
123 file = models.ForeignKey('StatementFile', blank=True, default=None, null=True, on_delete=models.CASCADE)
124 account = models.ForeignKey(Account, related_name='+', on_delete=models.PROTECT)
125 account_number = SafeCharField(_('account number'), max_length=32, db_index=True)
126 statement_identifier = SafeCharField(_('statement identifier'), max_length=48, db_index=True, blank=True, default='')
127 statement_number = models.SmallIntegerField(_('statement number'), db_index=True)
128 begin_date = models.DateField(_('begin date'), db_index=True)
129 end_date = models.DateField(_('end date'), db_index=True)
130 record_date = models.DateTimeField(_('record date'), db_index=True)
131 customer_identifier = SafeCharField(_('customer identifier'), max_length=64, blank=True, default='')
132 begin_balance_date = models.DateField(_('begin balance date'), null=True, blank=True, default=None)
133 begin_balance = models.DecimalField(_('begin balance'), max_digits=10, decimal_places=2)
134 record_count = models.IntegerField(_('record count'), null=True, default=None)
135 currency_code = SafeCharField(_('currency code'), max_length=3)
136 account_name = SafeCharField(_('account name'), max_length=32, blank=True, default='')
137 account_limit = models.DecimalField(_('account limit'), max_digits=10, decimal_places=2, blank=True, default=None, null=True)
138 owner_name = SafeCharField(_('owner name'), max_length=64)
139 contact_info_1 = SafeCharField(_('contact info (1)'), max_length=64, blank=True, default='')
140 contact_info_2 = SafeCharField(_('contact info (2)'), max_length=64, blank=True, default='')
141 bank_specific_info_1 = SafeCharField(_('bank specific info (1)'), max_length=1024, blank=True, default='')
142 iban = SafeCharField(_('IBAN'), max_length=32, db_index=True)
143 bic = SafeCharField(_('BIC'), max_length=11, db_index=True)
145 class Meta:
146 verbose_name = _('statement')
147 verbose_name_plural = _('statements')
150class PaymentRecordManager(AccountEntryManager):
151 def filter_matched(self):
152 return self.exclude(child_set=None)
154 def filter_unmatched(self):
155 return self.filter(child_set=None)
158class StatementRecord(AccountEntry):
159 objects: models.Manager = PaymentRecordManager() # type: ignore
160 statement = models.ForeignKey(Statement, verbose_name=_('statement'), related_name='record_set', on_delete=models.CASCADE)
161 line_number = models.SmallIntegerField(_('line number'), default=None, null=True, blank=True)
162 record_number = models.IntegerField(_('record number'), default=None, null=True, blank=True)
163 archive_identifier = SafeCharField(_('archive identifier'), max_length=64, blank=True, default='', db_index=True)
164 record_date = models.DateField(_('record date'), db_index=True)
165 value_date = models.DateField(_('value date'), db_index=True, blank=True, null=True, default=None)
166 paid_date = models.DateField(_('paid date'), db_index=True, blank=True, null=True, default=None)
167 entry_type = SafeCharField(_('entry type'), max_length=1, choices=RECORD_ENTRY_TYPE, db_index=True)
168 record_code = SafeCharField(_('record type'), max_length=4, choices=RECORD_CODES, db_index=True, blank=True)
169 record_domain = SafeCharField(_('record domain'), max_length=4, choices=RECORD_DOMAIN, db_index=True, blank=True)
170 family_code = SafeCharField(_('family code'), max_length=4, db_index=True, blank=True, default='')
171 sub_family_code = SafeCharField(_('sub family code'), max_length=4, db_index=True, blank=True, default='')
172 record_description = SafeCharField(_('record description'), max_length=128, blank=True, default='')
173 receipt_code = SafeCharField(_('receipt code'), max_length=1, choices=RECEIPT_CODE, db_index=True, blank=True)
174 delivery_method = SafeCharField(_('delivery method'), max_length=1, db_index=True, choices=DELIVERY_METHOD)
175 name = SafeCharField(_('name'), max_length=64, blank=True, db_index=True)
176 name_source = SafeCharField(_('name source'), max_length=1, blank=True, choices=NAME_SOURCES)
177 recipient_account_number = SafeCharField(_('recipient account number'), max_length=32, blank=True, db_index=True)
178 recipient_account_number_changed = SafeCharField(_('recipient account number changed'), max_length=1, blank=True)
179 remittance_info = SafeCharField(_('remittance info'), max_length=35, db_index=True, blank=True)
180 messages = SafeTextField(_('messages'), blank=True, default='')
181 client_messages = SafeTextField(_('client messages'), blank=True, default='')
182 bank_messages = SafeTextField(_('bank messages'), blank=True, default='')
183 manually_settled = models.BooleanField(_('manually settled'), db_index=True, default=False, blank=True)
185 class Meta:
186 verbose_name = _('statement record')
187 verbose_name_plural = _('statement records')
189 def clean(self):
190 self.source_file = self.statement
191 self.timestamp = pytz.utc.localize(datetime.combine(self.record_date, time(0, 0)))
192 if self.name:
193 self.description = '{name}: {record_description}'.format(record_description=self.record_description, name=self.name)
194 else:
195 self.description = '{record_description}'.format(record_description=self.record_description)
198class CurrencyExchangeSource(models.Model):
199 name = SafeCharField(_('name'), max_length=64)
200 created = models.DateTimeField(_('created'), default=now, db_index=True, blank=True, editable=False)
202 class Meta:
203 verbose_name = _('currency exchange source')
204 verbose_name_plural = _('currency exchange sources')
206 def __str__(self):
207 return str(self.name)
210class CurrencyExchange(models.Model):
211 record_date = models.DateField(_('record date'), db_index=True)
212 source_currency = SafeCharField(_('source currency'), max_length=3, blank=True)
213 target_currency = SafeCharField(_('target currency'), max_length=3, blank=True)
214 unit_currency = SafeCharField(_('unit currency'), max_length=3, blank=True)
215 exchange_rate = models.DecimalField(_('exchange rate'), decimal_places=6, max_digits=12, null=True, default=None, blank=True)
216 source = models.ForeignKey(CurrencyExchangeSource, verbose_name=_('currency exchange source'), blank=True, null=True, default=None, on_delete=models.PROTECT) # noqa
218 class Meta:
219 verbose_name = _('currency exchange')
220 verbose_name_plural = _('currency exchanges')
222 def __str__(self):
223 return '{src} = {rate} {tgt}'.format(src=self.source_currency, tgt=self.target_currency, rate=self.exchange_rate)
226class StatementRecordDetail(models.Model):
227 record = models.ForeignKey(StatementRecord, verbose_name=_('record'), related_name='detail_set', on_delete=models.CASCADE)
228 batch_identifier = SafeCharField(_('batch message id'), max_length=64, db_index=True, blank=True, default='')
229 amount = models.DecimalField(verbose_name=_('amount'), max_digits=10, decimal_places=2, blank=True, default=None, null=True, db_index=True)
230 currency_code = SafeCharField(_('currency code'), max_length=3)
231 instructed_amount = models.DecimalField(verbose_name=_('instructed amount'), max_digits=10, decimal_places=2, blank=True, default=None, null=True, db_index=True) # noqa
232 exchange = models.ForeignKey(CurrencyExchange, verbose_name=_('currency exchange'), related_name='recorddetail_set', on_delete=models.PROTECT, null=True, default=None, blank=True) # noqa
233 archive_identifier = SafeCharField(_('archive identifier'), max_length=64, blank=True)
234 end_to_end_identifier = SafeCharField(_('end-to-end identifier'), max_length=64, blank=True)
235 creditor_name = SafeCharField(_('creditor name'), max_length=128, blank=True)
236 creditor_account = SafeCharField(_('creditor account'), max_length=35, blank=True)
237 debtor_name = SafeCharField(_('debtor name'), max_length=128, blank=True)
238 ultimate_debtor_name = SafeCharField(_('ultimate debtor name'), max_length=128, blank=True)
239 unstructured_remittance_info = SafeCharField(_('unstructured remittance info'), max_length=2048, blank=True)
240 paid_date = models.DateTimeField(_('paid date'), db_index=True, blank=True, null=True, default=None)
243class StatementRecordRemittanceInfo(models.Model):
244 detail = models.ForeignKey(StatementRecordDetail, related_name='remittanceinfo_set', on_delete=models.CASCADE)
245 additional_info = SafeCharField(_('additional remittance info'), max_length=256, blank=True, db_index=True)
246 amount = models.DecimalField(_('amount'), decimal_places=2, max_digits=10, null=True, default=None, blank=True)
247 currency_code = SafeCharField(_('currency code'), max_length=3, blank=True)
248 reference = SafeCharField(_('reference'), max_length=35, blank=True, db_index=True)
250 def __str__(self):
251 return '{} {} ref {} ({})'.format(self.amount if self.amount is not None else '', self.currency_code, self.reference, self.additional_info)
254class StatementRecordSepaInfo(models.Model):
255 record = models.OneToOneField(StatementRecord, verbose_name=_('record'), related_name='sepa_info', on_delete=models.CASCADE)
256 reference = SafeCharField(_('reference'), max_length=35, blank=True)
257 iban_account_number = SafeCharField(_('IBAN'), max_length=35, blank=True)
258 bic_code = SafeCharField(_('BIC'), max_length=35, blank=True)
259 recipient_name_detail = SafeCharField(_('recipient name detail'), max_length=70, blank=True)
260 payer_name_detail = SafeCharField(_('payer name detail'), max_length=70, blank=True)
261 identifier = SafeCharField(_('identifier'), max_length=35, blank=True)
262 archive_identifier = SafeCharField(_('archive identifier'), max_length=64, blank=True)
264 class Meta:
265 verbose_name = _('SEPA')
266 verbose_name_plural = _('SEPA')
268 def __str__(self):
269 return '[{}]'.format(self.id)
272class ReferencePaymentBatchManager(models.Manager):
273 def latest_record_date(self) -> Optional[datetime]:
274 """
275 :return: datetime of latest record available or None
276 """
277 obj = self.order_by('-record_date').first()
278 if not obj:
279 return None
280 return obj.record_date
283class ReferencePaymentBatch(AccountEntrySourceFile):
284 objects = ReferencePaymentBatchManager()
285 file = models.ForeignKey('ReferencePaymentBatchFile', blank=True, default=None, null=True, on_delete=models.CASCADE)
286 record_date = models.DateTimeField(_('record date'), db_index=True)
287 institution_identifier = SafeCharField(_('institution identifier'), max_length=2, blank=True)
288 service_identifier = SafeCharField(_('service identifier'), max_length=9, blank=True)
289 currency_identifier = SafeCharField(_('currency identifier'), max_length=3, choices=CURRENCY_IDENTIFIERS)
291 class Meta:
292 verbose_name = _('reference payment batch')
293 verbose_name_plural = _('reference payment batches')
296class ReferencePaymentRecord(AccountEntry):
297 """
298 Reference payment record. See jacc.Invoice for date/time variable naming conventions.
299 """
300 objects = PaymentRecordManager() # type: ignore
301 batch = models.ForeignKey(ReferencePaymentBatch, verbose_name=_('batch'), related_name='record_set', on_delete=models.CASCADE)
302 line_number = models.SmallIntegerField(_('line number'), default=0, blank=True)
303 record_type = SafeCharField(_('record type'), max_length=1)
304 account_number = SafeCharField(_('account number'), max_length=32, db_index=True)
305 record_date = models.DateField(_('record date'), db_index=True)
306 paid_date = models.DateField(_('paid date'), db_index=True, blank=True, null=True, default=None)
307 archive_identifier = SafeCharField(_('archive identifier'), max_length=32, blank=True, default='', db_index=True)
308 remittance_info = SafeCharField(_('remittance info'), max_length=32, db_index=True)
309 payer_name = SafeCharField(_('payer name'), max_length=12, db_index=True)
310 currency_identifier = SafeCharField(_('currency identifier'), max_length=1, choices=CURRENCY_IDENTIFIERS)
311 name_source = SafeCharField(_('name source'), max_length=1, choices=NAME_SOURCES, blank=True)
312 correction_identifier = SafeCharField(_('correction identifier'), max_length=1, choices=CORRECTION_IDENTIFIER)
313 delivery_method = SafeCharField(_('delivery method'), max_length=1, db_index=True, choices=DELIVERY_METHOD)
314 receipt_code = SafeCharField(_('receipt code'), max_length=1, choices=RECEIPT_CODE, db_index=True, blank=True)
315 manually_settled = models.BooleanField(_('manually settled'), db_index=True, default=False, blank=True)
317 class Meta:
318 verbose_name = _('reference payment records')
319 verbose_name_plural = _('reference payment records')
321 @property
322 def remittance_info_short(self) -> str:
323 """
324 Remittance info without preceding zeroes.
325 :return: str
326 """
327 return re.sub(r'^0+', '', self.remittance_info)
329 def clean(self):
330 self.source_file = self.batch
331 self.timestamp = pytz.utc.localize(datetime.combine(self.paid_date, time(0, 0)))
332 self.description = '{amount} {remittance_info} {payer_name}'.format(amount=self.amount, remittance_info=self.remittance_info,
333 payer_name=self.payer_name)
336class StatementFile(models.Model):
337 created = models.DateTimeField(_('created'), default=now, db_index=True, blank=True, editable=False)
338 file = models.FileField(verbose_name=_('file'), upload_to='uploads')
339 original_filename = SafeCharField(_('original filename'), blank=True, default='', max_length=256)
340 tag = SafeCharField(_('tag'), blank=True, max_length=64, default='', db_index=True)
341 errors = SafeTextField(_('errors'), max_length=4086, default='', blank=True)
343 class Meta:
344 verbose_name = _('account statement file')
345 verbose_name_plural = _('account statement files')
347 @property
348 def full_path(self):
349 return join(settings.MEDIA_ROOT, self.file.name) if self.file else ''
351 def __str__(self):
352 return basename(str(self.file.name)) if self.file else ''
355class ReferencePaymentBatchFile(models.Model):
356 created = models.DateTimeField(_('created'), default=now, db_index=True, blank=True, editable=False)
357 file = models.FileField(verbose_name=_('file'), upload_to='uploads')
358 original_filename = SafeCharField(_('original filename'), blank=True, default='', max_length=256)
359 tag = SafeCharField(_('tag'), blank=True, max_length=64, default='', db_index=True)
360 errors = SafeTextField(_('errors'), max_length=4086, default='', blank=True)
362 class Meta:
363 verbose_name = _("reference payment batch file")
364 verbose_name_plural = _("reference payment batch files")
366 @property
367 def full_path(self):
368 return join(settings.MEDIA_ROOT, self.file.name) if self.file else ''
370 def __str__(self):
371 return basename(str(self.file.name)) if self.file else ''
374class PayoutParty(models.Model):
375 name = SafeCharField(_('name'), max_length=128, db_index=True)
376 account_number = SafeCharField(_('account number'), max_length=35, db_index=True, validators=[iban_validator])
377 bic = SafeCharField(_('BIC'), max_length=16, db_index=True, blank=True)
378 org_id = SafeCharField(_('organization id'), max_length=32, db_index=True, blank=True, default='')
379 address = SafeTextField(_('address'), blank=True, default='')
380 country_code = SafeCharField(_('country code'), max_length=2, default='FI', blank=True, db_index=True)
381 payouts_account = models.ForeignKey(Account, verbose_name=_('payouts account'), null=True, default=None, blank=True, on_delete=models.PROTECT)
383 class Meta:
384 verbose_name = _("payout party")
385 verbose_name_plural = _("payout parties")
387 def __str__(self):
388 return '{} ({})'.format(self.name, self.account_number)
390 def clean(self):
391 if not self.bic:
392 self.bic = iban_bic(self.account_number)
394 @property
395 def address_lines(self):
396 out = []
397 for line in self.address.split('\n'):
398 line = line.strip()
399 if line:
400 out.append(line)
401 return out
404class Payout(AccountEntry):
405 connection = models.ForeignKey('WsEdiConnection', verbose_name=_('WS-EDI connection'), on_delete=models.SET_NULL, null=True, blank=True, related_name='+')
406 payer = models.ForeignKey(PayoutParty, verbose_name=_('payer'), related_name='+', on_delete=models.PROTECT)
407 recipient = models.ForeignKey(PayoutParty, verbose_name=_('recipient'), related_name='+', on_delete=models.PROTECT)
408 messages = SafeTextField(_('recipient messages'), blank=True, default='')
409 reference = SafeCharField(_('recipient reference'), blank=True, default='', max_length=32)
410 msg_id = SafeCharField(_('message id'), max_length=64, blank=True, db_index=True, editable=False)
411 file_name = SafeCharField(_('file name'), max_length=255, blank=True, db_index=True, editable=False)
412 full_path = SafeTextField(_('full path'), blank=True, editable=False)
413 file_reference = SafeCharField(_('file reference'), max_length=255, blank=True, db_index=True, editable=False)
414 due_date = models.DateField(_('due date'), db_index=True, blank=True, null=True, default=None)
415 paid_date = models.DateTimeField(_('paid date'), db_index=True, blank=True, null=True, default=None)
416 state = SafeCharField(_('state'), max_length=1, blank=True, default=PAYOUT_WAITING_PROCESSING, choices=PAYOUT_STATE, db_index=True)
418 class Meta:
419 verbose_name = _("payout")
420 verbose_name_plural = _("payouts")
422 def clean(self):
423 if self.parent and not self.amount:
424 self.amount = self.parent.amount
426 # prevent defining both reference and messages
427 if self.messages and self.reference or not self.messages and not self.reference:
428 raise ValidationError(_('payment.must.have.reference.or.messages'))
430 # validate reference if any
431 if self.reference:
432 if self.reference[:2] == 'RF':
433 iso_payment_reference_validator(self.reference)
434 else:
435 fi_payment_reference_validator(self.reference)
437 # prevent canceling payouts which have been uploaded successfully
438 if self.state == PAYOUT_CANCELED:
439 if self.is_upload_done:
440 group_status = self.group_status
441 if group_status != 'RJCT':
442 raise ValidationError(_('File already uploaded') + ' ({})'.format(group_status))
444 # save paid time if marking payout as paid manually
445 if self.state == PAYOUT_PAID and not self.paid_date:
446 self.paid_date = now()
447 status = self.payoutstatus_set.order_by('-created').first()
448 if status:
449 assert isinstance(status, PayoutStatus)
450 self.paid_date = status.created
452 # always require amount
453 if self.amount is None or self.amount <= Decimal('0.00'):
454 raise ValidationError({'amount': _('value > 0 required')})
456 def generate_msg_id(self, commit: bool = True):
457 msg_id_base = re.sub(r'[^\d]', '', now().isoformat())[:-4]
458 self.msg_id = msg_id_base + 'P' + str(self.id)
459 if commit:
460 self.save(update_fields=['msg_id'])
462 @property
463 def state_name(self):
464 return choices_label(PAYOUT_STATE, self.state)
466 @property
467 def is_upload_done(self):
468 return PayoutStatus.objects.filter(payout=self, response_code='00').first() is not None
470 @property
471 def is_accepted(self):
472 return self.has_group_status('ACCP')
474 @property
475 def is_rejected(self):
476 return self.has_group_status('RJCT')
478 def has_group_status(self, group_status: str) -> bool:
479 return PayoutStatus.objects.filter(payout=self, group_status=group_status).first() is not None
481 @property
482 def group_status(self):
483 status = PayoutStatus.objects.filter(payout=self).order_by('-id').first()
484 return status.group_status if status else ''
485 group_status.fget.short_description = _('payment.group.status') # type: ignore # pytype: disable=attribute-error
488class PayoutStatusManager(models.Manager):
489 def is_file_processed(self, filename: str) -> bool:
490 return self.filter(file_name=basename(filename)).first() is not None
493class PayoutStatus(models.Model):
494 objects = PayoutStatusManager()
495 payout = models.ForeignKey(Payout, verbose_name=_('payout'), related_name='payoutstatus_set', on_delete=models.PROTECT, null=True, default=None, blank=True) # noqa
496 created = models.DateTimeField(_('created'), default=now, db_index=True, editable=False, blank=True)
497 file_name = SafeCharField(_('file name'), max_length=128, blank=True, db_index=True, editable=False)
498 file_path = SafeCharField(_('file path'), max_length=255, blank=True, db_index=True, editable=False)
499 response_code = SafeCharField(_('response code'), max_length=4, blank=True, db_index=True)
500 response_text = SafeCharField(_('response text'), max_length=128, blank=True)
501 msg_id = SafeCharField(_('message id'), max_length=64, blank=True, db_index=True)
502 original_msg_id = SafeCharField(_('original message id'), blank=True, max_length=64, db_index=True)
503 group_status = SafeCharField(_('group status'), max_length=8, blank=True, db_index=True)
504 status_reason = SafeCharField(_('status reason'), max_length=255, blank=True)
506 class Meta:
507 verbose_name = _("payout status")
508 verbose_name_plural = _("payout statuses")
510 def __str__(self):
511 return str(self.group_status)
513 @property
514 def full_path(self) -> str:
515 return get_media_full_path(self.file_path)
517 @property
518 def is_accepted(self):
519 return self.group_status == 'ACCP'
521 @property
522 def is_rejected(self):
523 return self.group_status == 'RJCT'
526class Refund(Payout):
527 class Meta:
528 verbose_name = _("refund")
529 verbose_name_plural = _("refunds")
532class WsEdiSoapCall(models.Model):
533 connection = models.ForeignKey('WsEdiConnection', verbose_name=_('WS-EDI connection'), on_delete=models.CASCADE)
534 command = SafeCharField(_('command'), max_length=64, blank=True, db_index=True)
535 created = models.DateTimeField(_('created'), default=now, db_index=True, editable=False, blank=True)
536 executed = models.DateTimeField(_('executed'), default=None, null=True, db_index=True, editable=False, blank=True)
537 error = SafeTextField(_('error'), blank=True)
539 class Meta:
540 verbose_name = _("WS-EDI SOAP call")
541 verbose_name_plural = _("WS-EDI SOAP calls")
543 def __str__(self):
544 return 'WsEdiSoapCall({})'.format(self.id)
546 @property
547 def timestamp(self) -> datetime:
548 return self.created.astimezone(pytz.timezone('Europe/Helsinki'))
550 @property
551 def timestamp_digits(self) -> str:
552 v = re.sub(r'[^\d]', '', self.created.isoformat())
553 return v[:17]
555 @property
556 def request_identifier(self) -> str:
557 return str(self.id)
559 @property
560 def command_camelcase(self) -> str:
561 return self.command[0:1].lower() + self.command[1:]
563 def debug_get_filename(self, file_type: str) -> str:
564 return '{:08}{}.xml'.format(self.id, file_type)
566 @property
567 def debug_request_full_path(self) -> str:
568 return self.debug_get_file_path(self.debug_get_filename('a'))
570 @property
571 def debug_response_full_path(self) -> str:
572 return self.debug_get_file_path(self.debug_get_filename('r'))
574 @staticmethod
575 def debug_get_file_path(filename: str) -> str:
576 return os.path.join(settings.WSEDI_LOG_PATH, filename) if hasattr(settings, 'WSEDI_LOG_PATH') and settings.WSEDI_LOG_PATH else ''
579class WsEdiConnectionManager(models.Manager):
580 def get_by_receiver_identifier(self, receiver_identifier: str):
581 objs = list(self.filter(receiver_identifier=receiver_identifier))
582 if len(objs) != 1:
583 raise ValidationError(_(
584 'WS-EDI connection cannot be found by receiver identifier {receiver_identifier} since there are {matches} matches').format(
585 receiver_identifier=receiver_identifier, matches=len(objs)))
586 return objs[0]
589class WsEdiConnection(models.Model):
590 objects = WsEdiConnectionManager()
591 name = SafeCharField(_('name'), max_length=64)
592 enabled = models.BooleanField(_('enabled'), blank=True, default=True)
593 sender_identifier = SafeCharField(_('sender identifier'), max_length=32)
594 receiver_identifier = SafeCharField(_('receiver identifier'), max_length=32)
595 target_identifier = SafeCharField(_('target identifier'), max_length=32)
596 environment = SafeCharField(_('environment'), max_length=32, default='PRODUCTION')
597 pin = SafeCharField('PIN', max_length=64, default='', blank=True)
598 pki_endpoint = models.URLField(_('PKI endpoint'), blank=True, default='')
599 bank_root_cert_file = models.FileField(verbose_name=_('bank root certificate file'), blank=True, upload_to='certs')
600 soap_endpoint = models.URLField(_('EDI endpoint'))
601 signing_cert_file = models.FileField(verbose_name=_('signing certificate file'), blank=True, upload_to='certs')
602 signing_key_file = models.FileField(verbose_name=_('signing key file'), blank=True, upload_to='certs')
603 encryption_cert_file = models.FileField(verbose_name=_('encryption certificate file'), blank=True, upload_to='certs')
604 encryption_key_file = models.FileField(verbose_name=_('encryption key file'), blank=True, upload_to='certs')
605 bank_encryption_cert_file = models.FileField(verbose_name=_('bank encryption cert file'), blank=True, upload_to='certs')
606 bank_signing_cert_file = models.FileField(verbose_name=_('bank signing cert file'), blank=True, upload_to='certs')
607 ca_cert_file = models.FileField(verbose_name=_('CA certificate file'), blank=True, upload_to='certs')
608 debug_commands = SafeTextField(_('debug commands'), blank=True, help_text=_('wsedi.connection.debug.commands.help.text'))
609 created = models.DateTimeField(_('created'), default=now, db_index=True, editable=False, blank=True)
610 _signing_cert = None
612 class Meta:
613 verbose_name = _("WS-EDI connection")
614 verbose_name_plural = _("WS-EDI connections")
616 def __str__(self):
617 return '{} / {}'.format(self.name, self.receiver_identifier)
619 @property
620 def signing_cert_full_path(self) -> str:
621 return get_media_full_path(self.signing_cert_file.file.name) if self.signing_cert_file else ''
623 @property
624 def signing_key_full_path(self) -> str:
625 return get_media_full_path(self.signing_key_file.file.name) if self.signing_key_file else ''
627 @property
628 def encryption_cert_full_path(self) -> str:
629 return get_media_full_path(self.encryption_cert_file.file.name) if self.encryption_cert_file else ''
631 @property
632 def encryption_key_full_path(self) -> str:
633 return get_media_full_path(self.encryption_key_file.file.name) if self.encryption_key_file else ''
635 @property
636 def bank_encryption_cert_full_path(self) -> str:
637 return get_media_full_path(self.bank_encryption_cert_file.file.name) if self.bank_encryption_cert_file else ''
639 @property
640 def bank_root_cert_full_path(self) -> str:
641 return get_media_full_path(self.bank_root_cert_file.file.name) if self.bank_root_cert_file else ''
643 @property
644 def ca_cert_full_path(self) -> str:
645 return get_media_full_path(self.ca_cert_file.file.name) if self.ca_cert_file else ''
647 @property
648 def signing_cert_with_public_key_full_path(self) -> str:
649 src_file = self.signing_cert_full_path
650 file = src_file[:-4] + '-with-pubkey.pem'
651 if not os.path.isfile(file):
652 cmd = [
653 settings.OPENSSL_PATH,
654 'x509',
655 '-pubkey',
656 '-in',
657 src_file,
658 ]
659 logger.info(' '.join(cmd))
660 out = subprocess.check_output(cmd)
661 with open(file, 'wb') as fp:
662 fp.write(out)
663 return file
665 @property
666 def bank_encryption_cert_with_public_key_full_path(self) -> str:
667 src_file = self.bank_encryption_cert_full_path
668 file = src_file[:-4] + '-with-pubkey.pem'
669 if not os.path.isfile(file):
670 cmd = [
671 settings.OPENSSL_PATH,
672 'x509',
673 '-pubkey',
674 '-in',
675 src_file,
676 ]
677 # logger.info(' '.join(cmd))
678 out = subprocess.check_output(cmd)
679 with open(file, 'wb') as fp:
680 fp.write(out)
681 return file
683 @property
684 def signing_cert(self):
685 if hasattr(self, '_signing_cert') and self._signing_cert:
686 return self._signing_cert
687 self._signing_cert = get_x509_cert_from_file(self.signing_cert_full_path)
688 return self._signing_cert
690 def get_pki_template(self, template_name: str, soap_call: WsEdiSoapCall, **kwargs) -> bytes:
691 return format_xml(get_template(template_name).render({
692 'ws': soap_call.connection,
693 'soap_call': soap_call,
694 'command': soap_call.command,
695 'timestamp': now().astimezone(pytz.timezone('Europe/Helsinki')).isoformat(),
696 **kwargs
697 })).encode()
699 def get_application_request(self, command: str, **kwargs) -> bytes:
700 return format_xml(get_template('jbank/application_request_template.xml').render({
701 'ws': self,
702 'command': command,
703 'timestamp': now().astimezone(pytz.timezone('Europe/Helsinki')).isoformat(),
704 **kwargs
705 })).encode()
707 @classmethod
708 def verify_signature(cls, content: bytes, signing_key_full_path: str):
709 with tempfile.NamedTemporaryFile() as fp:
710 fp.write(content)
711 fp.flush()
712 cmd = [
713 settings.XMLSEC1_PATH,
714 '--verify',
715 '--pubkey-pem',
716 signing_key_full_path,
717 fp.name
718 ]
719 # logger.info(' '.join(cmd))
720 subprocess.check_output(cmd)
722 def sign_pki_request(self, content: bytes) -> bytes:
723 return self._sign_request(content, self.signing_key_full_path, self.signing_cert_full_path)
725 def sign_application_request(self, content: bytes) -> bytes:
726 return self._sign_request(content, self.signing_key_full_path, self.signing_cert_full_path)
728 @classmethod
729 def _sign_request(cls, content: bytes, signing_key_full_path: str, signing_cert_full_path: str) -> bytes:
730 """
731 Sign a request.
732 See https://users.dcc.uchile.cl/~pcamacho/tutorial/web/xmlsec/xmlsec.html
733 :param content: XML application request
734 :param signing_key_full_path: Override signing key full path (if not use self.signing_key_full_path)
735 :param signing_cert_full_path: Override signing key full path (if not use self.signing_cert_full_path)
736 :return: str
737 """
738 with tempfile.NamedTemporaryFile() as fp:
739 fp.write(content)
740 fp.flush()
741 cmd = [
742 settings.XMLSEC1_PATH,
743 '--sign',
744 '--privkey-pem',
745 '{},{}'.format(signing_key_full_path, signing_cert_full_path),
746 fp.name
747 ]
748 # logger.info(' '.join(cmd))
749 out = subprocess.check_output(cmd)
750 cls.verify_signature(out, signing_key_full_path)
751 return out
753 def encrypt_pki_request(self, content: bytes) -> bytes:
754 return self._encrypt_request(content)
756 def encrypt_application_request(self, content: bytes) -> bytes:
757 return self._encrypt_request(content)
759 def _encrypt_request(self, content: bytes) -> bytes:
760 with tempfile.NamedTemporaryFile() as fp:
761 fp.write(content)
762 fp.flush()
763 cmd = [
764 self._xmlsec1_example_bin('encrypt3'),
765 fp.name,
766 self.bank_encryption_cert_with_public_key_full_path,
767 self.bank_encryption_cert_full_path
768 ]
769 # logger.info(' '.join(cmd))
770 out = subprocess.check_output(cmd)
771 return out
773 def encode_application_request(self, content: bytes) -> bytes:
774 lines = content.split(b'\n')
775 if lines and lines[0].startswith(b'<?xml'):
776 lines = lines[1:]
777 content_without_xml_tag = b'\n'.join(lines)
778 return base64.b64encode(content_without_xml_tag)
780 def decode_application_response(self, content: bytes) -> bytes:
781 return base64.b64decode(content)
783 def decrypt_application_response(self, content: bytes) -> bytes:
784 with tempfile.NamedTemporaryFile() as fp:
785 fp.write(content)
786 fp.flush()
787 cmd = [
788 self._xmlsec1_example_bin('decrypt3'),
789 fp.name,
790 self.encryption_key_full_path,
791 ]
792 # logger.info(' '.join(cmd))
793 out = subprocess.check_output(cmd)
794 return out
796 @property
797 def debug_command_list(self) -> List[str]:
798 return [x for x in re.sub(r'[^\w]+', ' ', self.debug_commands).strip().split(' ') if x]
800 @staticmethod
801 def _xmlsec1_example_bin(file: str) -> str:
802 if hasattr(settings, 'XMLSEC1_EXAMPLES_PATH') and settings.XMLSEC1_EXAMPLES_PATH:
803 xmlsec1_examples_path = settings.XMLSEC1_EXAMPLES_PATH
804 else:
805 xmlsec1_examples_path = os.path.join(str(os.getenv('HOME') or ''), 'bin/xmlsec1-examples')
806 return str(os.path.join(xmlsec1_examples_path, file))