Coverage for jbank/models.py: 79%

# Coverage for jbank/models.py : 79%

## 530 statements

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

1 import base64

2 import logging

3 import os

4 import re

5 import subprocess

6 import tempfile

7 from datetime import datetime, time

8 from decimal import Decimal

9 from os.path import basename, join

10 from pathlib import Path

11 from typing import List, Optional

12 import pytz

13 from django.conf import settings

14 from django.core.exceptions import ValidationError

15 from django.db import models

16 from django.template.loader import get_template

17 from django.utils.timezone import now

18 from django.utils.translation import gettext_lazy as _

19 from jacc.models import AccountEntry, AccountEntrySourceFile, Account, AccountEntryManager

20 from jbank.x509_helpers import get_x509_cert_from_file

21 from jutil.modelfields import SafeCharField, SafeTextField

22 from jutil.format import format_xml, get_media_full_path, choices_label

23 from jutil.validators import iban_validator, iban_bic, iso_payment_reference_validator, fi_payment_reference_validator

24

25

26 logger = logging.getLogger(__name__)

27

28

29 JBANK_BIN_PATH = Path(__file__).absolute().parent.joinpath('bin')

30

31 RECORD_ENTRY_TYPE = (

32 ('1', _('Deposit')),

33 ('2', _('Withdrawal')),

34 ('3', _('Deposit Correction')),

35 ('4', _('Withdrawal Correction')),

36)

37

38 RECORD_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)

60

61 RECORD_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)

72

73 RECEIPT_CODE = (

74 ('', ''),

75 ('E', _('Separate')),

76 ('P', _('Separate/Paper')),

77)

78

79 CURRENCY_IDENTIFIERS = (

80 ('1', 'EUR'),

81)

82

83 NAME_SOURCES = (

84 ('', _('Not Set')),

85 ('A', _('From Customer')),

86 ('K', _('From Bank Clerk')),

87 ('J', _('From Bank System')),

88)

89

90 CORRECTION_IDENTIFIER = (

91 ('0', _('Regular Entry')),

92 ('1', _('Correction Entry')),

93)

94

95 DELIVERY_FROM_CUSTOMER = 'A'

96 DELIVERY_FROM_BANK_CLERK = 'K'

97 DELIVERY_FROM_BANK_SYSTEM = 'J'

98

99 DELIVERY_METHOD = (

100 (DELIVERY_FROM_CUSTOMER, _('From Customer')),

101 (DELIVERY_FROM_BANK_CLERK, _('From Bank Clerk')),

102 (DELIVERY_FROM_BANK_SYSTEM, _('From Bank System')),

103)

104

105 PAYOUT_WAITING_PROCESSING = 'W'

106 PAYOUT_WAITING_UPLOAD = 'U'

107 PAYOUT_UPLOADED = 'D'

108 PAYOUT_PAID = 'P'

109 PAYOUT_CANCELED = 'C'

110 PAYOUT_ERROR = 'E'

111

112 PAYOUT_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)

120

121

122 class 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)

144

145 class Meta:

146 verbose_name = _('statement')

147 verbose_name_plural = _('statements')

148

149

150 class PaymentRecordManager(AccountEntryManager):

151 def filter_matched(self):

152 return self.exclude(child_set=None)

153

154 def filter_unmatched(self):

155 return self.filter(child_set=None)

156

157

158 class 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)

184

185 class Meta:

186 verbose_name = _('statement record')

187 verbose_name_plural = _('statement records')

188

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)

196

197

198 class CurrencyExchangeSource(models.Model):

199 name = SafeCharField(_('name'), max_length=64)

200 created = models.DateTimeField(_('created'), default=now, db_index=True, blank=True, editable=False)

201

202 class Meta:

203 verbose_name = _('currency exchange source')

204 verbose_name_plural = _('currency exchange sources')

205

206 def __str__(self):

207 return str(self.name)

208

209

210 class 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

217

218 class Meta:

219 verbose_name = _('currency exchange')

220 verbose_name_plural = _('currency exchanges')

221

222 def __str__(self):

223 return '{src} = {rate} {tgt}'.format(src=self.source_currency, tgt=self.target_currency, rate=self.exchange_rate)

224

225

226 class 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)

241

242

243 class 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)

249

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)

252

253

254 class 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)

263

264 class Meta:

265 verbose_name = _('SEPA')

266 verbose_name_plural = _('SEPA')

267

268 def __str__(self):

269 return '[{}]'.format(self.id)

270

271

272 class 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

281

282

283 class 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)

290

291 class Meta:

292 verbose_name = _('reference payment batch')

293 verbose_name_plural = _('reference payment batches')

294

295

296 class 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)

316

317 class Meta:

318 verbose_name = _('reference payment records')

319 verbose_name_plural = _('reference payment records')

320

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)

328

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)

334

335

336 class 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)

342

343 class Meta:

344 verbose_name = _('account statement file')

345 verbose_name_plural = _('account statement files')

346

347 @property

348 def full_path(self):

349 return join(settings.MEDIA_ROOT, self.file.name) if self.file else ''

350

351 def __str__(self):

352 return basename(str(self.file.name)) if self.file else ''

353

354

355 class 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)

361

362 class Meta:

363 verbose_name = _("reference payment batch file")

364 verbose_name_plural = _("reference payment batch files")

365

366 @property

367 def full_path(self):

368 return join(settings.MEDIA_ROOT, self.file.name) if self.file else ''

369

370 def __str__(self):

371 return basename(str(self.file.name)) if self.file else ''

372

373

374 class 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)

382

383 class Meta:

384 verbose_name = _("payout party")

385 verbose_name_plural = _("payout parties")

386

387 def __str__(self):

388 return '{} ({})'.format(self.name, self.account_number)

389

390 def clean(self):

391 if not self.bic:

392 self.bic = iban_bic(self.account_number)

393

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

402

403

404 class 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)

417

418 class Meta:

419 verbose_name = _("payout")

420 verbose_name_plural = _("payouts")

421

422 def clean(self):

423 if self.parent and not self.amount:

424 self.amount = self.parent.amount

425

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

429

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)

436

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

443

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

451

452 # always require amount

453 if self.amount is None or self.amount <= Decimal('0.00'):

454 raise ValidationError({'amount': _('value > 0 required')})

455

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'])

461

462 @property

463 def state_name(self):

464 return choices_label(PAYOUT_STATE, self.state)

465

466 @property

467 def is_upload_done(self):

468 return PayoutStatus.objects.filter(payout=self, response_code='00').first() is not None

469

470 @property

471 def is_accepted(self):

472 return self.has_group_status('ACCP')

473

474 @property

475 def is_rejected(self):

476 return self.has_group_status('RJCT')

477

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

480

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

486

487

488 class PayoutStatusManager(models.Manager):

489 def is_file_processed(self, filename: str) -> bool:

490 return self.filter(file_name=basename(filename)).first() is not None

491

492

493 class 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)

505

506 class Meta:

507 verbose_name = _("payout status")

508 verbose_name_plural = _("payout statuses")

509

510 def __str__(self):

511 return str(self.group_status)

512

513 @property

514 def full_path(self) -> str:

515 return get_media_full_path(self.file_path)

516

517 @property

518 def is_accepted(self):

519 return self.group_status == 'ACCP'

520

521 @property

522 def is_rejected(self):

523 return self.group_status == 'RJCT'

524

525

526 class Refund(Payout):

527 class Meta:

528 verbose_name = _("refund")

529 verbose_name_plural = _("refunds")

530

531

532 class 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)

538

539 class Meta:

540 verbose_name = _("WS-EDI SOAP call")

541 verbose_name_plural = _("WS-EDI SOAP calls")

542

543 def __str__(self):

544 return 'WsEdiSoapCall({})'.format(self.id)

545

546 @property

547 def timestamp(self) -> datetime:

548 return self.created.astimezone(pytz.timezone('Europe/Helsinki'))

549

550 @property

551 def timestamp_digits(self) -> str:

552 v = re.sub(r'[^\d]', '', self.created.isoformat())

553 return v[:17]

554

555 @property

556 def request_identifier(self) -> str:

557 return str(self.id)

558

559 @property

560 def command_camelcase(self) -> str:

561 return self.command[0:1].lower() + self.command[1:]

562

563 def debug_get_filename(self, file_type: str) -> str:

564 return '{:08}{}.xml'.format(self.id, file_type)

565

566 @property

567 def debug_request_full_path(self) -> str:

568 return self.debug_get_file_path(self.debug_get_filename('a'))

569

570 @property

571 def debug_response_full_path(self) -> str:

572 return self.debug_get_file_path(self.debug_get_filename('r'))

573

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 ''

577

578

579 class 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]

587

588

589 class 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

611

612 class Meta:

613 verbose_name = _("WS-EDI connection")

614 verbose_name_plural = _("WS-EDI connections")

615

616 def __str__(self):

617 return '{} / {}'.format(self.name, self.receiver_identifier)

618

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 ''

622

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 ''

626

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 ''

630

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 ''

634

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 ''

638

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 ''

642

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 ''

646

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

664

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

682

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

689

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

698

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

706

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)

721

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)

724

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)

727

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

752

753 def encrypt_pki_request(self, content: bytes) -> bytes:

754 return self._encrypt_request(content)

755

756 def encrypt_application_request(self, content: bytes) -> bytes:

757 return self._encrypt_request(content)

758

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

772

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)

779

780 def decode_application_response(self, content: bytes) -> bytes:

781 return base64.b64decode(content)

782

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

795

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]

799

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