Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1import base64 

2import logging 

3import os 

4import re 

5import subprocess 

6import tempfile 

7from datetime import datetime, time 

8from decimal import Decimal 

9from os.path import basename, join 

10from pathlib import Path 

11from typing import List, Optional 

12import pytz 

13from django.conf import settings 

14from django.core.exceptions import ValidationError 

15from django.db import models 

16from django.template.loader import get_template 

17from django.utils.timezone import now 

18from django.utils.translation import gettext_lazy as _ 

19from jacc.helpers import sum_queryset 

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

21from jbank.x509_helpers import get_x509_cert_from_file 

22from jutil.modelfields import SafeCharField, SafeTextField 

23from jutil.format import format_xml, get_media_full_path, choices_label 

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

25 

26 

27logger = logging.getLogger(__name__) 

28 

29 

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

31 

32RECORD_ENTRY_TYPE = ( 

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

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

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

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

37) 

38 

39RECORD_CODES = ( 

40 ("700", _("Money Transfer (In/Out)")), 

41 ("701", _("Recurring Payment (In/Out)")), 

42 ("702", _("Bill Payment (Out)")), 

43 ("703", _("Payment Terminal Deposit (In)")), 

44 ("704", _("Bank Draft (In/Out)")), 

45 ("705", _("Reference Payments (In)")), 

46 ("706", _("Payment Service (Out)")), 

47 ("710", _("Deposit (In)")), 

48 ("720", _("Withdrawal (Out)")), 

49 ("721", _("Card Payment (Out)")), 

50 ("722", _("Check (Out)")), 

51 ("730", _("Bank Fees (Out)")), 

52 ("740", _("Interests Charged (Out)")), 

53 ("750", _("Interests Credited (In)")), 

54 ("760", _("Loan (Out)")), 

55 ("761", _("Loan Payment (Out)")), 

56 ("770", _("Foreign Transfer (In/Out)")), 

57 ("780", _("Zero Balancing (In/Out)")), 

58 ("781", _("Sweeping (In/Out)")), 

59 ("782", _("Topping (In/Out)")), 

60) 

61 

62RECORD_DOMAIN = ( 

63 ("PMNT", _("Money Transfer (In/Out)")), 

64 ("LDAS", _("Loan Payment (Out)")), 

65 ("CAMT", _("Cash Management")), 

66 ("ACMT", _("Account Management")), 

67 ("XTND", _("Entended Domain")), 

68 ("SECU", _("Securities")), 

69 ("FORX", _("Foreign Exchange")), 

70 ("XTND", _("Entended Domain")), 

71 ("NTAV", _("Not Available")), 

72) 

73 

74RECEIPT_CODE = ( 

75 ("", ""), 

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

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

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

79) 

80 

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

82 

83NAME_SOURCES = ( 

84 ("", _("Not Set")), 

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

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

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

88) 

89 

90CORRECTION_IDENTIFIER = ( 

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

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

93) 

94 

95DELIVERY_METHOD_UNKNOWN = "" 

96DELIVERY_FROM_CUSTOMER = "A" 

97DELIVERY_FROM_BANK_CLERK = "K" 

98DELIVERY_FROM_BANK_SYSTEM = "J" 

99 

100DELIVERY_METHOD = ( 

101 (DELIVERY_METHOD_UNKNOWN, ""), 

102 (DELIVERY_FROM_CUSTOMER, _("From Customer")), 

103 (DELIVERY_FROM_BANK_CLERK, _("From Bank Clerk")), 

104 (DELIVERY_FROM_BANK_SYSTEM, _("From Bank System")), 

105) 

106 

107PAYOUT_WAITING_PROCESSING = "W" 

108PAYOUT_WAITING_UPLOAD = "U" 

109PAYOUT_UPLOADED = "D" 

110PAYOUT_PAID = "P" 

111PAYOUT_CANCELED = "C" 

112PAYOUT_ERROR = "E" 

113 

114PAYOUT_STATE = ( 

115 (PAYOUT_WAITING_PROCESSING, _("waiting processing")), 

116 (PAYOUT_WAITING_UPLOAD, _("waiting upload")), 

117 (PAYOUT_UPLOADED, _("uploaded")), 

118 (PAYOUT_PAID, _("paid")), 

119 (PAYOUT_CANCELED, _("canceled")), 

120 (PAYOUT_ERROR, _("error")), 

121) 

122 

123 

124class Statement(AccountEntrySourceFile): 

125 file = models.ForeignKey("StatementFile", blank=True, default=None, null=True, on_delete=models.CASCADE) 

126 account = models.ForeignKey(Account, related_name="+", on_delete=models.PROTECT) 

127 account_number = SafeCharField(_("account number"), max_length=32, db_index=True) 

128 statement_identifier = SafeCharField( 

129 _("statement identifier"), max_length=48, db_index=True, blank=True, default="" 

130 ) 

131 statement_number = models.SmallIntegerField(_("statement number"), db_index=True) 

132 begin_date = models.DateField(_("begin date"), db_index=True) 

133 end_date = models.DateField(_("end date"), db_index=True) 

134 record_date = models.DateTimeField(_("record date"), db_index=True) 

135 customer_identifier = SafeCharField(_("customer identifier"), max_length=64, blank=True, default="") 

136 begin_balance_date = models.DateField(_("begin balance date"), null=True, blank=True, default=None) 

137 begin_balance = models.DecimalField(_("begin balance"), max_digits=10, decimal_places=2) 

138 record_count = models.IntegerField(_("record count"), null=True, default=None) 

139 currency_code = SafeCharField(_("currency code"), max_length=3) 

140 account_name = SafeCharField(_("account name"), max_length=32, blank=True, default="") 

141 account_limit = models.DecimalField( 

142 _("account limit"), max_digits=10, decimal_places=2, blank=True, default=None, null=True 

143 ) 

144 owner_name = SafeCharField(_("owner name"), max_length=64) 

145 contact_info_1 = SafeCharField(_("contact info (1)"), max_length=64, blank=True, default="") 

146 contact_info_2 = SafeCharField(_("contact info (2)"), max_length=64, blank=True, default="") 

147 bank_specific_info_1 = SafeCharField(_("bank specific info (1)"), max_length=1024, blank=True, default="") 

148 iban = SafeCharField(_("IBAN"), max_length=32, db_index=True) 

149 bic = SafeCharField(_("BIC"), max_length=11, db_index=True) 

150 

151 class Meta: 

152 verbose_name = _("statement") 

153 verbose_name_plural = _("statements") 

154 

155 

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

164class StatementRecord(AccountEntry): 

165 objects: models.Manager = PaymentRecordManager() # type: ignore 

166 statement = models.ForeignKey( 

167 Statement, verbose_name=_("statement"), related_name="record_set", on_delete=models.CASCADE 

168 ) 

169 line_number = models.SmallIntegerField(_("line number"), default=None, null=True, blank=True) 

170 record_number = models.IntegerField(_("record number"), default=None, null=True, blank=True) 

171 archive_identifier = SafeCharField(_("archive identifier"), max_length=64, blank=True, default="", db_index=True) 

172 record_date = models.DateField(_("record date"), db_index=True) 

173 value_date = models.DateField(_("value date"), db_index=True, blank=True, null=True, default=None) 

174 paid_date = models.DateField(_("paid date"), db_index=True, blank=True, null=True, default=None) 

175 entry_type = SafeCharField(_("entry type"), max_length=1, choices=RECORD_ENTRY_TYPE, db_index=True) 

176 record_code = SafeCharField(_("record type"), max_length=4, choices=RECORD_CODES, db_index=True, blank=True) 

177 record_domain = SafeCharField(_("record domain"), max_length=4, choices=RECORD_DOMAIN, db_index=True, blank=True) 

178 family_code = SafeCharField(_("family code"), max_length=4, db_index=True, blank=True, default="") 

179 sub_family_code = SafeCharField(_("sub family code"), max_length=4, db_index=True, blank=True, default="") 

180 record_description = SafeCharField(_("record description"), max_length=128, blank=True, default="") 

181 receipt_code = SafeCharField(_("receipt code"), max_length=1, choices=RECEIPT_CODE, db_index=True, blank=True) 

182 delivery_method = SafeCharField( 

183 _("delivery method"), max_length=1, db_index=True, choices=DELIVERY_METHOD, blank=True 

184 ) 

185 name = SafeCharField(_("name"), max_length=64, blank=True, db_index=True) 

186 name_source = SafeCharField(_("name source"), max_length=1, blank=True, choices=NAME_SOURCES) 

187 recipient_account_number = SafeCharField(_("recipient account number"), max_length=32, blank=True, db_index=True) 

188 recipient_account_number_changed = SafeCharField(_("recipient account number changed"), max_length=1, blank=True) 

189 remittance_info = SafeCharField(_("remittance info"), max_length=35, db_index=True, blank=True) 

190 messages = SafeTextField(_("messages"), blank=True, default="") 

191 client_messages = SafeTextField(_("client messages"), blank=True, default="") 

192 bank_messages = SafeTextField(_("bank messages"), blank=True, default="") 

193 manually_settled = models.BooleanField(_("manually settled"), db_index=True, default=False, blank=True) 

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 

217class CurrencyExchangeSource(models.Model): 

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

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

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 

229class CurrencyExchange(models.Model): 

230 record_date = models.DateField(_("record date"), db_index=True) 

231 source_currency = SafeCharField(_("source currency"), max_length=3, blank=True) 

232 target_currency = SafeCharField(_("target currency"), max_length=3, blank=True) 

233 unit_currency = SafeCharField(_("unit currency"), max_length=3, blank=True) 

234 exchange_rate = models.DecimalField( 

235 _("exchange rate"), decimal_places=6, max_digits=12, null=True, default=None, blank=True 

236 ) 

237 source = models.ForeignKey( 

238 CurrencyExchangeSource, 

239 verbose_name=_("currency exchange source"), 

240 blank=True, 

241 null=True, 

242 default=None, 

243 on_delete=models.PROTECT, 

244 ) # noqa 

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 

256class StatementRecordDetail(models.Model): 

257 record = models.ForeignKey( 

258 StatementRecord, verbose_name=_("record"), related_name="detail_set", on_delete=models.CASCADE 

259 ) 

260 batch_identifier = SafeCharField(_("batch message id"), max_length=64, db_index=True, blank=True, default="") 

261 amount = models.DecimalField( 

262 verbose_name=_("amount"), max_digits=10, decimal_places=2, blank=True, default=None, null=True, db_index=True 

263 ) 

264 currency_code = SafeCharField(_("currency code"), max_length=3) 

265 instructed_amount = models.DecimalField( 

266 verbose_name=_("instructed amount"), 

267 max_digits=10, 

268 decimal_places=2, 

269 blank=True, 

270 default=None, 

271 null=True, 

272 db_index=True, 

273 ) 

274 exchange = models.ForeignKey( 

275 CurrencyExchange, 

276 verbose_name=_("currency exchange"), 

277 related_name="recorddetail_set", 

278 on_delete=models.PROTECT, 

279 null=True, 

280 default=None, 

281 blank=True, 

282 ) 

283 archive_identifier = SafeCharField(_("archive identifier"), max_length=64, blank=True) 

284 end_to_end_identifier = SafeCharField(_("end-to-end identifier"), max_length=64, blank=True) 

285 creditor_name = SafeCharField(_("creditor name"), max_length=128, blank=True) 

286 creditor_account = SafeCharField(_("creditor account"), max_length=35, blank=True) 

287 creditor_account_scheme = SafeCharField(_("creditor account scheme"), max_length=8, blank=True) 

288 debtor_name = SafeCharField(_("debtor name"), max_length=128, blank=True) 

289 ultimate_debtor_name = SafeCharField(_("ultimate debtor name"), max_length=128, blank=True) 

290 unstructured_remittance_info = SafeCharField(_("unstructured remittance info"), max_length=2048, blank=True) 

291 paid_date = models.DateTimeField(_("paid date"), db_index=True, blank=True, null=True, default=None) 

292 

293 class Meta: 

294 verbose_name = _("statement record details") 

295 verbose_name_plural = _("statement record details") 

296 

297 

298class StatementRecordRemittanceInfo(models.Model): 

299 detail = models.ForeignKey(StatementRecordDetail, related_name="remittanceinfo_set", on_delete=models.CASCADE) 

300 additional_info = SafeCharField(_("additional remittance info"), max_length=256, blank=True, db_index=True) 

301 amount = models.DecimalField(_("amount"), decimal_places=2, max_digits=10, null=True, default=None, blank=True) 

302 currency_code = SafeCharField(_("currency code"), max_length=3, blank=True) 

303 reference = SafeCharField(_("reference"), max_length=35, blank=True, db_index=True) 

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 

315class StatementRecordSepaInfo(models.Model): 

316 record = models.OneToOneField( 

317 StatementRecord, verbose_name=_("record"), related_name="sepa_info", on_delete=models.CASCADE 

318 ) 

319 reference = SafeCharField(_("reference"), max_length=35, blank=True) 

320 iban_account_number = SafeCharField(_("IBAN"), max_length=35, blank=True) 

321 bic_code = SafeCharField(_("BIC"), max_length=35, blank=True) 

322 recipient_name_detail = SafeCharField(_("recipient name detail"), max_length=70, blank=True) 

323 payer_name_detail = SafeCharField(_("payer name detail"), max_length=70, blank=True) 

324 identifier = SafeCharField(_("identifier"), max_length=35, blank=True) 

325 archive_identifier = SafeCharField(_("archive identifier"), max_length=64, blank=True) 

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 

335class ReferencePaymentBatchManager(models.Manager): 

336 def latest_record_date(self) -> Optional[datetime]: 

337 """ 

338 :return: datetime of latest record available or None 

339 """ 

340 obj = self.order_by("-record_date").first() 

341 if not obj: 

342 return None 

343 return obj.record_date 

344 

345 

346class ReferencePaymentBatch(AccountEntrySourceFile): 

347 objects = ReferencePaymentBatchManager() 

348 file = models.ForeignKey("ReferencePaymentBatchFile", blank=True, default=None, null=True, on_delete=models.CASCADE) 

349 record_date = models.DateTimeField(_("record date"), db_index=True) 

350 institution_identifier = SafeCharField(_("institution identifier"), max_length=2, blank=True) 

351 service_identifier = SafeCharField(_("service identifier"), max_length=9, blank=True) 

352 currency_identifier = SafeCharField(_("currency identifier"), max_length=3, choices=CURRENCY_IDENTIFIERS) 

353 cached_total_amount = models.DecimalField( 

354 _("total amount"), max_digits=10, decimal_places=2, null=True, default=None, blank=True 

355 ) 

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 

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

427class StatementFile(models.Model): 

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

429 file = models.FileField(verbose_name=_("file"), upload_to="uploads") 

430 original_filename = SafeCharField(_("original filename"), blank=True, default="", max_length=256) 

431 tag = SafeCharField(_("tag"), blank=True, max_length=64, default="", db_index=True) 

432 errors = SafeTextField(_("errors"), max_length=4086, default="", blank=True) 

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 

446class ReferencePaymentBatchFile(models.Model): 

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

448 file = models.FileField(verbose_name=_("file"), upload_to="uploads") 

449 original_filename = SafeCharField(_("original filename"), blank=True, default="", max_length=256) 

450 tag = SafeCharField(_("tag"), blank=True, max_length=64, default="", db_index=True) 

451 errors = SafeTextField(_("errors"), max_length=4086, default="", blank=True) 

452 cached_total_amount = models.DecimalField( 

453 _("total amount"), max_digits=10, decimal_places=2, null=True, default=None, blank=True 

454 ) 

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 

480class PayoutParty(models.Model): 

481 name = SafeCharField(_("name"), max_length=128, db_index=True) 

482 account_number = SafeCharField(_("account number"), max_length=35, db_index=True, validators=[iban_validator]) 

483 bic = SafeCharField(_("BIC"), max_length=16, db_index=True, blank=True) 

484 org_id = SafeCharField(_("organization id"), max_length=32, db_index=True, blank=True, default="") 

485 address = SafeTextField(_("address"), blank=True, default="") 

486 country_code = SafeCharField(_("country code"), max_length=2, default="FI", blank=True, db_index=True) 

487 payouts_account = models.ForeignKey( 

488 Account, verbose_name=_("payouts account"), null=True, default=None, blank=True, on_delete=models.PROTECT 

489 ) 

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 

512class Payout(AccountEntry): 

513 connection = models.ForeignKey( 

514 "WsEdiConnection", 

515 verbose_name=_("WS-EDI connection"), 

516 on_delete=models.SET_NULL, 

517 null=True, 

518 blank=True, 

519 related_name="+", 

520 ) 

521 payer = models.ForeignKey(PayoutParty, verbose_name=_("payer"), related_name="+", on_delete=models.PROTECT) 

522 recipient = models.ForeignKey(PayoutParty, verbose_name=_("recipient"), related_name="+", on_delete=models.PROTECT) 

523 messages = SafeTextField(_("recipient messages"), blank=True, default="") 

524 reference = SafeCharField(_("recipient reference"), blank=True, default="", max_length=32) 

525 msg_id = SafeCharField(_("message id"), max_length=64, blank=True, db_index=True, editable=False) 

526 file_name = SafeCharField(_("file name"), max_length=255, blank=True, db_index=True, editable=False) 

527 full_path = SafeTextField(_("full path"), blank=True, editable=False) 

528 file_reference = SafeCharField(_("file reference"), max_length=255, blank=True, db_index=True, editable=False) 

529 due_date = models.DateField(_("due date"), db_index=True, blank=True, null=True, default=None) 

530 paid_date = models.DateTimeField(_("paid date"), db_index=True, blank=True, null=True, default=None) 

531 state = SafeCharField( 

532 _("state"), max_length=1, blank=True, default=PAYOUT_WAITING_PROCESSING, choices=PAYOUT_STATE, db_index=True 

533 ) 

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 

606class PayoutStatusManager(models.Manager): 

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

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

609 

610 

611class PayoutStatus(models.Model): 

612 objects = PayoutStatusManager() 

613 payout = models.ForeignKey( 

614 Payout, 

615 verbose_name=_("payout"), 

616 related_name="payoutstatus_set", 

617 on_delete=models.PROTECT, 

618 null=True, 

619 default=None, 

620 blank=True, 

621 ) 

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

623 file_name = SafeCharField(_("file name"), max_length=128, blank=True, db_index=True, editable=False) 

624 file_path = SafeCharField(_("file path"), max_length=255, blank=True, db_index=True, editable=False) 

625 response_code = SafeCharField(_("response code"), max_length=4, blank=True, db_index=True) 

626 response_text = SafeCharField(_("response text"), max_length=128, blank=True) 

627 msg_id = SafeCharField(_("message id"), max_length=64, blank=True, db_index=True) 

628 original_msg_id = SafeCharField(_("original message id"), blank=True, max_length=64, db_index=True) 

629 group_status = SafeCharField(_("group status"), max_length=8, blank=True, db_index=True) 

630 status_reason = SafeCharField(_("status reason"), max_length=255, blank=True) 

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 

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

660class WsEdiSoapCall(models.Model): 

661 connection = models.ForeignKey("WsEdiConnection", verbose_name=_("WS-EDI connection"), on_delete=models.CASCADE) 

662 command = SafeCharField(_("command"), max_length=64, blank=True, db_index=True) 

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

664 executed = models.DateTimeField(_("executed"), default=None, null=True, db_index=True, editable=False, blank=True) 

665 error = SafeTextField(_("error"), blank=True) 

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 

711class WsEdiConnectionManager(models.Manager): 

712 def get_by_receiver_identifier(self, receiver_identifier: str): 

713 objs = list(self.filter(receiver_identifier=receiver_identifier)) 

714 if len(objs) != 1: 

715 raise ValidationError( 

716 _( 

717 "WS-EDI connection cannot be found by receiver identifier {receiver_identifier} since there are {matches} matches" 

718 ).format(receiver_identifier=receiver_identifier, matches=len(objs)) 

719 ) 

720 return objs[0] 

721 

722 

723class WsEdiConnection(models.Model): 

724 objects = WsEdiConnectionManager() 

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

726 enabled = models.BooleanField(_("enabled"), blank=True, default=True) 

727 sender_identifier = SafeCharField(_("sender identifier"), max_length=32) 

728 receiver_identifier = SafeCharField(_("receiver identifier"), max_length=32) 

729 target_identifier = SafeCharField(_("target identifier"), max_length=32) 

730 environment = SafeCharField(_("environment"), max_length=32, default="PRODUCTION") 

731 pin = SafeCharField("PIN", max_length=64, default="", blank=True) 

732 pki_endpoint = models.URLField(_("PKI endpoint"), blank=True, default="") 

733 bank_root_cert_file = models.FileField(verbose_name=_("bank root certificate file"), blank=True, upload_to="certs") 

734 soap_endpoint = models.URLField(_("EDI endpoint")) 

735 signing_cert_file = models.FileField(verbose_name=_("signing certificate file"), blank=True, upload_to="certs") 

736 signing_key_file = models.FileField(verbose_name=_("signing key file"), blank=True, upload_to="certs") 

737 encryption_cert_file = models.FileField( 

738 verbose_name=_("encryption certificate file"), blank=True, upload_to="certs" 

739 ) 

740 encryption_key_file = models.FileField(verbose_name=_("encryption key file"), blank=True, upload_to="certs") 

741 bank_encryption_cert_file = models.FileField( 

742 verbose_name=_("bank encryption cert file"), blank=True, upload_to="certs" 

743 ) 

744 bank_signing_cert_file = models.FileField(verbose_name=_("bank signing cert file"), blank=True, upload_to="certs") 

745 ca_cert_file = models.FileField(verbose_name=_("CA certificate file"), blank=True, upload_to="certs") 

746 debug_commands = SafeTextField( 

747 _("debug commands"), blank=True, help_text=_("wsedi.connection.debug.commands.help.text") 

748 ) 

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

750 _signing_cert = None 

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