Coverage for jbank/sepa.py: 87%

195 statements  

« prev     ^ index     » next       coverage.py v7.2.2, created at 2023-03-27 13:36 +0700

1# pylint: disable=too-many-arguments 

2import sys 

3from collections import OrderedDict 

4from datetime import datetime, date 

5from typing import Optional, List, Sequence, Union, Any, Dict, Tuple 

6from xml.etree import ElementTree as ET # noqa 

7from xml.etree.ElementTree import Element 

8from decimal import Decimal 

9import pytz 

10from django.core.exceptions import ValidationError 

11from django.utils.timezone import now 

12from django.utils.translation import gettext_lazy as _ 

13from jutil.format import dec2 

14from jutil.parse import parse_datetime 

15from jutil.validators import ( 

16 iban_filter, 

17 iban_validator, 

18 iso_payment_reference_validator, 

19 fi_payment_reference_validator, 

20 ascii_filter, 

21 country_code_validator, 

22 bic_validator, 

23) 

24from jutil.xml import xml_to_dict, _xml_element_set_data_r 

25 

26 

27PAIN001_REMITTANCE_INFO_MSG = "M" 

28PAIN001_REMITTANCE_INFO_OCR = "O" 

29PAIN001_REMITTANCE_INFO_OCR_ISO = "I" 

30 

31PAIN001_REMITTANCE_INFO_TYPE = ( 

32 (PAIN001_REMITTANCE_INFO_MSG, _("message")), 

33 (PAIN001_REMITTANCE_INFO_OCR, _("OCR")), 

34 (PAIN001_REMITTANCE_INFO_OCR_ISO, _("OCR/ISO")), 

35) 

36 

37PAIN001_REMITTANCE_INFO_VALUES = [t[0] for t in PAIN001_REMITTANCE_INFO_TYPE] 

38 

39 

40class Pain001Party: 

41 def __init__( 

42 self, 

43 name: str, 

44 account: str, 

45 bic: str, 

46 org_id: str = "", 

47 address_lines: Optional[Sequence[str]] = None, 

48 country_code: str = "", 

49 ): 

50 if address_lines is None: 

51 address_lines = [] 

52 account = iban_filter(account) 

53 iban_validator(account) 

54 self.name = name 

55 self.account = account 

56 self.bic = bic 

57 self.org_id = org_id 

58 self.address_lines = address_lines 

59 self.country_code = country_code 

60 

61 

62class Pain001Payment: 

63 def __init__( 

64 self, 

65 payment_id: Union[str, int], 

66 creditor: Pain001Party, 

67 amount: Decimal, 

68 remittance_info: str, 

69 remittance_info_type: str, 

70 due_date: date, 

71 ): 

72 self.payment_id = payment_id 

73 self.creditor = creditor 

74 self.amount = amount 

75 self.remittance_info = remittance_info 

76 self.remittance_info_type = remittance_info_type 

77 self.due_date = due_date 

78 

79 def clean(self): 

80 if not self.remittance_info: 

81 raise ValidationError(_("pain001.remittance.info.missing")) 

82 if self.remittance_info_type not in PAIN001_REMITTANCE_INFO_VALUES: 

83 raise ValidationError(_("pain001.remittance.info.type.invalid")) 

84 if self.remittance_info_type == PAIN001_REMITTANCE_INFO_MSG: 

85 if not self.remittance_info: 

86 raise ValidationError(_("Invalid payment reference: {}").format(self.remittance_info)) 

87 elif self.remittance_info_type == PAIN001_REMITTANCE_INFO_OCR: 

88 fi_payment_reference_validator(self.remittance_info) 

89 elif self.remittance_info_type == PAIN001_REMITTANCE_INFO_OCR_ISO: 

90 iso_payment_reference_validator(self.remittance_info) 

91 

92 

93class Pain001: 

94 """ 

95 Class for generating pain.001.001.03 SEPA payment XML files. 

96 """ 

97 

98 pain_element_name = "CstmrCdtTrfInitn" 

99 tz_str = "Europe/Helsinki" 

100 tz: Any = None 

101 xml_declaration: Any = None 

102 

103 def __init__( 

104 self, 

105 msg_id: str, 

106 debtor_name: str, 

107 debtor_account: str, 

108 debtor_bic: str, 

109 debtor_org_id: str, 

110 debtor_address_lines: Sequence[str], 

111 debtor_country_code: str, 

112 ): 

113 if not debtor_org_id or len(debtor_org_id) < 5: 

114 raise ValidationError({"debtor_org_id": _("invalid value")}) 

115 if not debtor_name or len(debtor_name) < 2: 

116 raise ValidationError({"debtor_name": _("invalid value")}) 

117 if not debtor_address_lines: 

118 raise ValidationError({"debtor_address_lines": _("invalid value")}) 

119 bic_validator(debtor_bic) 

120 country_code_validator(debtor_country_code) 

121 iban_validator(debtor_account) 

122 

123 self.msg_id = msg_id 

124 self.debtor = Pain001Party(debtor_name, debtor_account, debtor_bic, debtor_org_id, debtor_address_lines, debtor_country_code) 

125 self.payments: List[Pain001Payment] = [] 

126 

127 def add_payment( 

128 self, 

129 payment_id, 

130 creditor_name: str, 

131 creditor_account: str, 

132 creditor_bic: str, 

133 amount: Decimal, 

134 remittance_info: str, 

135 remittance_info_type: str = PAIN001_REMITTANCE_INFO_MSG, 

136 due_date: Optional[date] = None, 

137 ): 

138 if not due_date: 

139 due_date = self._local_time().date() 

140 creditor = Pain001Party(creditor_name, creditor_account, creditor_bic) 

141 p = Pain001Payment(payment_id, creditor, dec2(amount), remittance_info, remittance_info_type, due_date) 

142 p.clean() 

143 self.payments.append(p) 

144 

145 def _ctrl_sum(self) -> Decimal: 

146 total = Decimal("0.00") 

147 for p in self.payments: 

148 assert isinstance(p, Pain001Payment) 

149 total += p.amount 

150 return total 

151 

152 def _append_simple(self, parent: Element, tag: str, value): 

153 e = Element(tag) 

154 e.text = str(value) 

155 parent.append(e) 

156 return e 

157 

158 def _local_time(self, t: Optional[datetime] = None) -> datetime: 

159 if not t: 

160 t = now() 

161 if not self.tz: 

162 self.tz = pytz.timezone(self.tz_str) 

163 return t.astimezone(self.tz) 

164 

165 def _timestamp(self, t: datetime) -> str: 

166 return self._local_time(t).isoformat() 

167 

168 @staticmethod 

169 def _dict_to_element(doc: Dict[str, Any], value_key: str = "@", attribute_prefix: str = "@") -> Element: 

170 if len(doc) != 1: 

171 raise Exception("Invalid data dict for XML generation, document root must have single element") 

172 for tag, data in doc.items(): 

173 el = Element(tag) 

174 assert isinstance(el, Element) 

175 _xml_element_set_data_r(el, data, value_key, attribute_prefix) 

176 return el # pytype: disable=bad-return-type 

177 return Element("empty") 

178 

179 def _grp_hdr(self) -> Element: 

180 g = Element("GrpHdr") 

181 self._append_simple(g, "MsgId", self.msg_id) 

182 self._append_simple(g, "CreDtTm", self._timestamp(now())) 

183 self._append_simple(g, "NbOfTxs", len(self.payments)) 

184 self._append_simple(g, "CtrlSum", self._ctrl_sum()) 

185 # self._append_simple(g, 'BtchBookg', 'true') # debit all at once 

186 # self._append_simple(g, 'Grpg', 'MIXD') 

187 g.append( 

188 self._dict_to_element( 

189 { 

190 "InitgPty": OrderedDict( 

191 [ 

192 ("Nm", self.debtor.name), 

193 ( 

194 "PstlAdr", 

195 OrderedDict( 

196 [ 

197 ("Ctry", self.debtor.country_code), 

198 ("AdrLine", [{"@": al} for al in self.debtor.address_lines]), 

199 ] 

200 ), 

201 ), 

202 ] 

203 ), 

204 } 

205 ) 

206 ) 

207 return g 

208 

209 def _pmt_inf(self, p: Pain001Payment) -> Element: 

210 rmt_inf: Tuple[str, Any] 

211 if p.remittance_info_type == PAIN001_REMITTANCE_INFO_MSG: 

212 rmt_inf = ( 

213 "RmtInf", 

214 OrderedDict( 

215 [ 

216 ("Ustrd", p.remittance_info), 

217 ] 

218 ), 

219 ) 

220 elif p.remittance_info_type == PAIN001_REMITTANCE_INFO_OCR: 

221 rmt_inf = ( 

222 "RmtInf", 

223 OrderedDict( 

224 [ 

225 ( 

226 "Strd", 

227 OrderedDict( 

228 [ 

229 ( 

230 "CdtrRefInf", 

231 OrderedDict( 

232 [ 

233 ( 

234 "Tp", 

235 OrderedDict( 

236 [ 

237 ( 

238 "CdOrPrtry", 

239 OrderedDict( 

240 [ 

241 ("Cd", "SCOR"), 

242 ] 

243 ), 

244 ), 

245 ] 

246 ), 

247 ), 

248 ("Ref", p.remittance_info), 

249 ] 

250 ), 

251 ), 

252 ] 

253 ), 

254 ), 

255 ] 

256 ), 

257 ) 

258 elif p.remittance_info_type == PAIN001_REMITTANCE_INFO_OCR_ISO: 

259 rmt_inf = ( 

260 "RmtInf", 

261 OrderedDict( 

262 [ 

263 ( 

264 "Strd", 

265 OrderedDict( 

266 [ 

267 ( 

268 "CdtrRefInf", 

269 OrderedDict( 

270 [ 

271 ( 

272 "Tp", 

273 OrderedDict( 

274 [ 

275 ( 

276 "CdOrPrtry", 

277 OrderedDict( 

278 [ 

279 ("Cd", "SCOR"), 

280 ] 

281 ), 

282 ), 

283 ("Issr", "ISO"), 

284 ] 

285 ), 

286 ), 

287 ("Ref", p.remittance_info), 

288 ] 

289 ), 

290 ), 

291 ] 

292 ), 

293 ), 

294 ] 

295 ), 

296 ) 

297 else: 

298 raise ValidationError(_("Invalid remittance info type: {}").format(p.remittance_info_type)) 

299 

300 return self._dict_to_element( 

301 { 

302 "PmtInf": OrderedDict( 

303 [ 

304 ("PmtInfId", str(p.payment_id)), 

305 ("PmtMtd", "TRF"), # payment order 

306 ("ReqdExctnDt", p.due_date.isoformat()), 

307 ( 

308 "Dbtr", 

309 OrderedDict( 

310 [ 

311 ("Nm", self.debtor.name), 

312 ( 

313 "PstlAdr", 

314 OrderedDict( 

315 [ 

316 ("Ctry", self.debtor.country_code), 

317 ("AdrLine", [{"@": al} for al in self.debtor.address_lines]), 

318 ] 

319 ), 

320 ), 

321 ( 

322 "Id", 

323 OrderedDict( 

324 [ 

325 ( 

326 "OrgId", 

327 OrderedDict( 

328 [ 

329 ( 

330 "Othr", 

331 OrderedDict( 

332 [ 

333 ("Id", self.debtor.org_id), 

334 ( 

335 "SchmeNm", 

336 OrderedDict( 

337 [ 

338 ("Cd", "BANK"), 

339 ] 

340 ), 

341 ), 

342 ] 

343 ), 

344 ), 

345 ] 

346 ), 

347 ), 

348 ] 

349 ), 

350 ), 

351 ] 

352 ), 

353 ), 

354 ( 

355 "DbtrAcct", 

356 OrderedDict( 

357 [ 

358 ( 

359 "Id", 

360 OrderedDict( 

361 [ 

362 ("IBAN", self.debtor.account), 

363 ] 

364 ), 

365 ), 

366 ] 

367 ), 

368 ), 

369 ( 

370 "DbtrAgt", 

371 OrderedDict( 

372 [ 

373 ( 

374 "FinInstnId", 

375 OrderedDict( 

376 [ 

377 ("BIC", self.debtor.bic), 

378 ] 

379 ), 

380 ), 

381 ] 

382 ), 

383 ), 

384 ("ChrgBr", "SLEV"), # FollowingService level 

385 ( 

386 "CdtTrfTxInf", 

387 OrderedDict( 

388 [ 

389 ( 

390 "PmtId", 

391 OrderedDict( 

392 [ 

393 ("EndToEndId", str(p.payment_id)), 

394 ] 

395 ), 

396 ), 

397 ( 

398 "Amt", 

399 OrderedDict( 

400 [ 

401 ("InstdAmt", {"@": str(p.amount), "@Ccy": "EUR"}), 

402 ] 

403 ), 

404 ), 

405 ( 

406 "UltmtDbtr", 

407 OrderedDict( 

408 [ 

409 ("Nm", self.debtor.name), 

410 ] 

411 ), 

412 ), 

413 ( 

414 "CdtrAgt", 

415 OrderedDict( 

416 [ 

417 ( 

418 "FinInstnId", 

419 OrderedDict( 

420 [ 

421 ("BIC", p.creditor.bic), 

422 ] 

423 ), 

424 ), 

425 ] 

426 ), 

427 ), 

428 ( 

429 "Cdtr", 

430 OrderedDict( 

431 [ 

432 ("Nm", ascii_filter(p.creditor.name)), 

433 ] 

434 ), 

435 ), 

436 ( 

437 "CdtrAcct", 

438 OrderedDict( 

439 [ 

440 ( 

441 "Id", 

442 OrderedDict( 

443 [ 

444 ("IBAN", p.creditor.account), 

445 ] 

446 ), 

447 ), 

448 ] 

449 ), 

450 ), 

451 rmt_inf, 

452 ] 

453 ), 

454 ), 

455 ] 

456 ), 

457 } 

458 ) 

459 

460 def render_to_element(self) -> Element: 

461 if not self.payments: 

462 raise ValidationError("No payments in pain.001.001.03") 

463 doc = Element("Document", xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03") 

464 pain = Element(self.pain_element_name) 

465 doc.append(pain) 

466 pain.append(self._grp_hdr()) 

467 for p in self.payments: 

468 assert isinstance(p, Pain001Payment) 

469 pain.append(self._pmt_inf(p)) 

470 return doc 

471 

472 def render_to_bytes(self, doc: Optional[Element] = None) -> bytes: 

473 doc = doc or self.render_to_element() 

474 if sys.version_info.major == 3 and sys.version_info.minor < 8: 

475 xml_bytes = ET.tostring(doc, encoding="utf-8", method="xml") 

476 else: 

477 xml_bytes = ET.tostring(doc, encoding="utf-8", method="xml", xml_declaration=self.xml_declaration) 

478 return xml_bytes 

479 

480 def render_to_file(self, filename: str, xml_bytes: Optional[bytes] = None): 

481 xml_bytes = xml_bytes or self.render_to_bytes() 

482 with open(filename, "wb") as fp: 

483 fp.write(xml_bytes) 

484 

485 

486class Pain002: 

487 """ 

488 Class for parsing pain.002.001.03 SEPA payment status XML files. 

489 """ 

490 

491 credit_datetime: datetime 

492 msg_id: str = "" 

493 original_msg_id: str = "" 

494 group_status: str = "" 

495 status_reason: str = "" 

496 

497 def __init__(self, file_content: bytes): 

498 self.data = xml_to_dict(file_content) 

499 

500 rpt = self.data.get("CstmrPmtStsRpt", {}) 

501 

502 grp_hdr = rpt.get("GrpHdr", {}) 

503 credit_datetime = parse_datetime(grp_hdr.get("CreDtTm")) 

504 if credit_datetime is None: 

505 raise ValidationError("CreDtTm missing") 

506 assert isinstance(credit_datetime, datetime) 

507 self.credit_datetime = credit_datetime 

508 self.msg_id = grp_hdr.get("MsgId") 

509 

510 grp_inf = rpt.get("OrgnlGrpInfAndSts", {}) 

511 self.original_msg_id = grp_inf.get("OrgnlMsgId") 

512 pmt_inf = rpt.get("OrgnlPmtInfAndSts") or {} 

513 

514 self.group_status = grp_inf.get("GrpSts") 

515 if not self.group_status: 

516 self.group_status = pmt_inf.get("PmtInfSts") or "" 

517 self.status_reason = grp_inf.get("StsRsnInf", {}).get("Rsn", {}).get("Prtry", "") 

518 if not self.status_reason: 

519 self.status_reason = (pmt_inf.get("StsRsnInf") or {}).get("AddtlInf") or "" 

520 

521 if not self.msg_id: 

522 raise ValidationError("MsgId missing") 

523 if not self.original_msg_id: 

524 raise ValidationError("OrgnlMsgId missing") 

525 if not self.group_status: 

526 raise ValidationError("GrpSts missing") 

527 

528 def __str__(self): 

529 return "{}: {} {} {}".format(self.msg_id, self.original_msg_id, self.group_status, self.status_reason) 

530 

531 @property 

532 def is_accepted(self): 

533 return self.group_status in ["ACCP", "ACSC", "ACSP"] 

534 

535 @property 

536 def is_technically_accepted(self): 

537 return self.group_status == "ACTC" 

538 

539 @property 

540 def is_accepted_with_change(self): 

541 return self.group_status == "ACWC" 

542 

543 @property 

544 def is_partially_accepted(self): 

545 return self.group_status == "PART" 

546 

547 @property 

548 def is_pending(self): 

549 return self.group_status == "PDNG" 

550 

551 @property 

552 def is_rejected(self): 

553 return self.group_status == "RJCT"