Coverage for jbank/models.py: 79%

# Coverage for jbank/models.py : 79%

## 566 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.helpers import sum_queryset

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

21 from jbank.x509_helpers import get_x509_cert_from_file

22 from jutil.modelfields import SafeCharField, SafeTextField

23 from jutil.format import format_xml, get_media_full_path, choices_label

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

25

26

27 logger = logging.getLogger(__name__)

28

29

30 JBANK_BIN_PATH = Path(__file__).absolute().parent.joinpath("bin")

31

32 RECORD_ENTRY_TYPE = (

33 ("1", _("Deposit")),

34 ("2", _("Withdrawal")),

35 ("3", _("Deposit Correction")),

36 ("4", _("Withdrawal Correction")),

37)

38

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

61

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

73

74 RECEIPT_CODE = (

75 ("", ""),

76 ("0", "(0)"),

77 ("E", _("Separate")),

78 ("P", _("Separate/Paper")),

79)

80

81 CURRENCY_IDENTIFIERS = (("1", "EUR"),)

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_METHOD_UNKNOWN = ""

96 DELIVERY_FROM_CUSTOMER = "A"

97 DELIVERY_FROM_BANK_CLERK = "K"

98 DELIVERY_FROM_BANK_SYSTEM = "J"

99

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

106

107 PAYOUT_WAITING_PROCESSING = "W"

108 PAYOUT_WAITING_UPLOAD = "U"

109 PAYOUT_UPLOADED = "D"

110 PAYOUT_PAID = "P"

111 PAYOUT_CANCELED = "C"

112 PAYOUT_ERROR = "E"

113

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

122

123

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

150

151 class Meta:

152 verbose_name = _("statement")

153 verbose_name_plural = _("statements")

154

155

156 class PaymentRecordManager(AccountEntryManager):

157 def filter_matched(self):

158 return self.exclude(child_set=None)

159

160 def filter_unmatched(self):

161 return self.filter(child_set=None)

162

163

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

194

195 class Meta:

196 verbose_name = _("statement record")

197 verbose_name_plural = _("statement records")

198

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

205

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)

215

216

217 class CurrencyExchangeSource(models.Model):

218 name = SafeCharField(_("name"), max_length=64)

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

220

221 class Meta:

222 verbose_name = _("currency exchange source")

223 verbose_name_plural = _("currency exchange sources")

224

225 def __str__(self):

226 return str(self.name)

227

228

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

245

246 class Meta:

247 verbose_name = _("currency exchange")

248 verbose_name_plural = _("currency exchanges")

249

250 def __str__(self):

251 return "{src} = {rate} {tgt}".format(

252 src=self.source_currency, tgt=self.target_currency, rate=self.exchange_rate

253 )

254

255

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

292

293 class Meta:

294 verbose_name = _("statement record details")

295 verbose_name_plural = _("statement record details")

296

297

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

304

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 )

309

310 class Meta:

311 verbose_name = _("statement record remittance info")

312 verbose_name_plural = _("statement record remittance info")

313

314

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

326

327 class Meta:

328 verbose_name = _("SEPA")

329 verbose_name_plural = _("SEPA")

330

331 def __str__(self):

332 return "[{}]".format(self.id)

333

334

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

344

345

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

356

357 class Meta:

358 verbose_name = _("reference payment batch")

359 verbose_name_plural = _("reference payment batches")

360

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

366

367 @property

368 def total_amount(self) -> Decimal:

369 return self.get_total_amount()

370

371 total_amount.fget.short_description = _("total amount") # type: ignore

372

373

374 class ReferencePaymentRecord(AccountEntry):

375 """

376 Reference payment record. See jacc.Invoice for date/time variable naming conventions.

377 """

378

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)

399

400 class Meta:

401 verbose_name = _("reference payment records")

402 verbose_name_plural = _("reference payment records")

403

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

410

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)

418

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 )

425

426

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

433

434 class Meta:

435 verbose_name = _("account statement file")

436 verbose_name_plural = _("account statement files")

437

438 @property

439 def full_path(self):

440 return join(settings.MEDIA_ROOT, self.file.name) if self.file else ""

441

442 def __str__(self):

443 return basename(str(self.file.name)) if self.file else ""

444

445

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

455

456 class Meta:

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

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

459

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

465

466 @property

467 def total_amount(self) -> Decimal:

468 return self.get_total_amount()

469

470 total_amount.fget.short_description = _("total amount") # type: ignore

471

472 @property

473 def full_path(self):

474 return join(settings.MEDIA_ROOT, self.file.name) if self.file else ""

475

476 def __str__(self):

477 return basename(str(self.file.name)) if self.file else ""

478

479

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

490

491 class Meta:

492 verbose_name = _("payout party")

493 verbose_name_plural = _("payout parties")

494

495 def __str__(self):

496 return "{} ({})".format(self.name, self.account_number)

497

498 def clean(self):

499 if not self.bic:

500 self.bic = iban_bic(self.account_number)

501

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

510

511

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

534

535 class Meta:

536 verbose_name = _("payout")

537 verbose_name_plural = _("payouts")

538

539 def clean(self):

540 if self.parent and not self.amount:

541 self.amount = self.parent.amount

542

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

546

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)

553

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

560

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

568

569 # always require amount

570 if self.amount is None or self.amount <= Decimal("0.00"):

571 raise ValidationError({"amount": _("value > 0 required")})

572

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

578

579 @property

580 def state_name(self):

581 return choices_label(PAYOUT_STATE, self.state)

582

583 @property

584 def is_upload_done(self):

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

586

587 @property

588 def is_accepted(self):

589 return self.has_group_status("ACCP")

590

591 @property

592 def is_rejected(self):

593 return self.has_group_status("RJCT")

594

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

597

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

602

603 group_status.fget.short_description = _("payment.group.status") # type: ignore # pytype: disable=attribute-error

604

605

606 class PayoutStatusManager(models.Manager):

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

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

609

610

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

631

632 class Meta:

633 verbose_name = _("payout status")

634 verbose_name_plural = _("payout statuses")

635

636 def __str__(self):

637 return str(self.group_status)

638

639 @property

640 def full_path(self) -> str:

641 return get_media_full_path(self.file_path) if self.file_path else ""

642

643 @property

644 def is_accepted(self):

645 return self.group_status == "ACCP"

646

647 @property

648 def is_rejected(self):

649 return self.group_status == "RJCT"

650

651

652 class Refund(Payout):

653 class Meta:

654 verbose_name = _("incoming.payment.refund")

655 verbose_name_plural = _("incoming.payment.refunds")

656

657 attachment = models.FileField(verbose_name=_("attachment"), blank=True, upload_to="uploads")

658

659

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

666

667 class Meta:

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

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

670

671 def __str__(self):

672 return "WsEdiSoapCall({})".format(self.id)

673

674 @property

675 def timestamp(self) -> datetime:

676 return self.created.astimezone(pytz.timezone("Europe/Helsinki"))

677

678 @property

679 def timestamp_digits(self) -> str:

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

681 return v[:17]

682

683 @property

684 def request_identifier(self) -> str:

685 return str(self.id)

686

687 @property

688 def command_camelcase(self) -> str:

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

690

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

692 return "{:08}{}.xml".format(self.id, file_type)

693

694 @property

695 def debug_request_full_path(self) -> str:

696 return self.debug_get_file_path(self.debug_get_filename("q"))

697

698 @property

699 def debug_response_full_path(self) -> str:

700 return self.debug_get_file_path(self.debug_get_filename("s"))

701

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 )

709

710

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

721

722

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

751

752 class Meta:

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

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

755

756 def __str__(self):

757 return "{} / {}".format(self.name, self.receiver_identifier)

758

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

762

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

766

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

770

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

774

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

778

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

782

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

786

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

804

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

822

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

829

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

842

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

854

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)

863

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)

866

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)

869

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

894

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

896 return self._encrypt_request(content)

897

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

899 return self._encrypt_request(content)

900

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

914

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)

921

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

923 return base64.b64decode(content)

924

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

937

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]

941

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