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

1# pylint: disable=too-many-arguments 

2from collections import OrderedDict 

3from datetime import datetime, date 

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

5from xml.etree import ElementTree as ET # noqa 

6from xml.etree.ElementTree import Element 

7from decimal import Decimal 

8import pytz 

9from django.core.exceptions import ValidationError 

10from django.utils.timezone import now 

11from django.utils.translation import gettext_lazy as _ 

12from jutil.format import dec2 

13from jutil.parse import parse_datetime 

14from jutil.validators import ( 

15 iban_filter, 

16 iban_validator, 

17 iso_payment_reference_validator, 

18 fi_payment_reference_validator, 

19 ascii_filter, 

20 country_code_validator, 

21 bic_validator, 

22) 

23from jutil.xml import xml_to_dict, _xml_element_set_data_r 

24 

25 

26PAIN001_REMITTANCE_INFO_MSG = "M" 

27PAIN001_REMITTANCE_INFO_OCR = "O" 

28PAIN001_REMITTANCE_INFO_OCR_ISO = "I" 

29 

30PAIN001_REMITTANCE_INFO_TYPE = ( 

31 (PAIN001_REMITTANCE_INFO_MSG, _("message")), 

32 (PAIN001_REMITTANCE_INFO_OCR, _("OCR")), 

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

34) 

35 

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

37 

38 

39class Pain001Party: 

40 def __init__( 

41 self, 

42 name: str, 

43 account: str, 

44 bic: str, 

45 org_id: str = "", 

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

47 country_code: str = "", 

48 ): 

49 if address_lines is None: 

50 address_lines = [] 

51 account = iban_filter(account) 

52 iban_validator(account) 

53 self.name = name 

54 self.account = account 

55 self.bic = bic 

56 self.org_id = org_id 

57 self.address_lines = address_lines 

58 self.country_code = country_code 

59 

60 

61class Pain001Payment: 

62 def __init__( 

63 self, 

64 payment_id: Union[str, int], 

65 creditor: Pain001Party, 

66 amount: Decimal, 

67 remittance_info: str, 

68 remittance_info_type: str, 

69 due_date: date, 

70 ): 

71 self.payment_id = payment_id 

72 self.creditor = creditor 

73 self.amount = amount 

74 self.remittance_info = remittance_info 

75 self.remittance_info_type = remittance_info_type 

76 self.due_date = due_date 

77 

78 def clean(self): 

79 if not self.remittance_info: 

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

81 if self.remittance_info_type not in PAIN001_REMITTANCE_INFO_VALUES: 

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

83 if self.remittance_info_type == PAIN001_REMITTANCE_INFO_MSG: 

84 if not self.remittance_info: 

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

86 elif self.remittance_info_type == PAIN001_REMITTANCE_INFO_OCR: 

87 fi_payment_reference_validator(self.remittance_info) 

88 elif self.remittance_info_type == PAIN001_REMITTANCE_INFO_OCR_ISO: 

89 iso_payment_reference_validator(self.remittance_info) 

90 

91 

92class Pain001: 

93 """ 

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

95 """ 

96 

97 pain_element_name = "CstmrCdtTrfInitn" 

98 tz_str = "Europe/Helsinki" 

99 tz: Any = None 

100 xml_declaration: Any = None 

101 

102 def __init__( 

103 self, 

104 msg_id: str, 

105 debtor_name: str, 

106 debtor_account: str, 

107 debtor_bic: str, 

108 debtor_org_id: str, 

109 debtor_address_lines: Sequence[str], 

110 debtor_country_code: str, 

111 ): 

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

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

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

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

116 if not debtor_address_lines: 

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

118 bic_validator(debtor_bic) 

119 country_code_validator(debtor_country_code) 

120 iban_validator(debtor_account) 

121 

122 self.msg_id = msg_id 

123 self.debtor = Pain001Party( 

124 debtor_name, debtor_account, debtor_bic, debtor_org_id, debtor_address_lines, debtor_country_code 

125 ) 

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

127 

128 def add_payment( 

129 self, 

130 payment_id, 

131 creditor_name: str, 

132 creditor_account: str, 

133 creditor_bic: str, 

134 amount: Decimal, 

135 remittance_info: str, 

136 remittance_info_type: str = PAIN001_REMITTANCE_INFO_MSG, 

137 due_date: date = None, 

138 ): 

139 if not due_date: 

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

141 creditor = Pain001Party(creditor_name, creditor_account, creditor_bic) 

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

143 p.clean() 

144 self.payments.append(p) 

145 

146 def _ctrl_sum(self) -> Decimal: 

147 total = Decimal("0.00") 

148 for p in self.payments: 

149 assert isinstance(p, Pain001Payment) 

150 total += p.amount 

151 return total 

152 

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

154 e = Element(tag) 

155 e.text = str(value) 

156 parent.append(e) 

157 return e 

158 

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

160 if not t: 

161 t = now() 

162 if not self.tz: 

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

164 return t.astimezone(self.tz) 

165 

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

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

168 

169 @staticmethod 

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

171 if len(doc) != 1: 

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

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

174 el = Element(tag) 

175 assert isinstance(el, Element) 

176 _xml_element_set_data_r(el, data, value_key, attribute_prefix) 

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

178 return Element("empty") 

179 

180 def _grp_hdr(self) -> Element: 

181 g = Element("GrpHdr") 

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

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

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

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

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

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

188 g.append( 

189 self._dict_to_element( 

190 { 

191 "InitgPty": OrderedDict( 

192 [ 

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

194 ( 

195 "PstlAdr", 

196 OrderedDict( 

197 [ 

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

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

200 ] 

201 ), 

202 ), 

203 ] 

204 ), 

205 } 

206 ) 

207 ) 

208 return g 

209 

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

211 rmt_inf: Tuple[str, Any] 

212 if p.remittance_info_type == PAIN001_REMITTANCE_INFO_MSG: 

213 rmt_inf = ( 

214 "RmtInf", 

215 OrderedDict( 

216 [ 

217 ("Ustrd", p.remittance_info), 

218 ] 

219 ), 

220 ) 

221 elif p.remittance_info_type == PAIN001_REMITTANCE_INFO_OCR: 

222 rmt_inf = ( 

223 "RmtInf", 

224 OrderedDict( 

225 [ 

226 ( 

227 "Strd", 

228 OrderedDict( 

229 [ 

230 ( 

231 "CdtrRefInf", 

232 OrderedDict( 

233 [ 

234 ( 

235 "Tp", 

236 OrderedDict( 

237 [ 

238 ( 

239 "CdOrPrtry", 

240 OrderedDict( 

241 [ 

242 ("Cd", "SCOR"), 

243 ] 

244 ), 

245 ), 

246 ] 

247 ), 

248 ), 

249 ("Ref", p.remittance_info), 

250 ] 

251 ), 

252 ), 

253 ] 

254 ), 

255 ), 

256 ] 

257 ), 

258 ) 

259 elif p.remittance_info_type == PAIN001_REMITTANCE_INFO_OCR_ISO: 

260 rmt_inf = ( 

261 "RmtInf", 

262 OrderedDict( 

263 [ 

264 ( 

265 "Strd", 

266 OrderedDict( 

267 [ 

268 ( 

269 "CdtrRefInf", 

270 OrderedDict( 

271 [ 

272 ( 

273 "Tp", 

274 OrderedDict( 

275 [ 

276 ( 

277 "CdOrPrtry", 

278 OrderedDict( 

279 [ 

280 ("Cd", "SCOR"), 

281 ] 

282 ), 

283 ), 

284 ("Issr", "ISO"), 

285 ] 

286 ), 

287 ), 

288 ("Ref", p.remittance_info), 

289 ] 

290 ), 

291 ), 

292 ] 

293 ), 

294 ), 

295 ] 

296 ), 

297 ) 

298 else: 

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

300 

301 return self._dict_to_element( 

302 { 

303 "PmtInf": OrderedDict( 

304 [ 

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

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

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

308 ( 

309 "Dbtr", 

310 OrderedDict( 

311 [ 

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

313 ( 

314 "PstlAdr", 

315 OrderedDict( 

316 [ 

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

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

319 ] 

320 ), 

321 ), 

322 ( 

323 "Id", 

324 OrderedDict( 

325 [ 

326 ( 

327 "OrgId", 

328 OrderedDict( 

329 [ 

330 ( 

331 "Othr", 

332 OrderedDict( 

333 [ 

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

335 ( 

336 "SchmeNm", 

337 OrderedDict( 

338 [ 

339 ("Cd", "BANK"), 

340 ] 

341 ), 

342 ), 

343 ] 

344 ), 

345 ), 

346 ] 

347 ), 

348 ), 

349 ] 

350 ), 

351 ), 

352 ] 

353 ), 

354 ), 

355 ( 

356 "DbtrAcct", 

357 OrderedDict( 

358 [ 

359 ( 

360 "Id", 

361 OrderedDict( 

362 [ 

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

364 ] 

365 ), 

366 ), 

367 ] 

368 ), 

369 ), 

370 ( 

371 "DbtrAgt", 

372 OrderedDict( 

373 [ 

374 ( 

375 "FinInstnId", 

376 OrderedDict( 

377 [ 

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

379 ] 

380 ), 

381 ), 

382 ] 

383 ), 

384 ), 

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

386 ( 

387 "CdtTrfTxInf", 

388 OrderedDict( 

389 [ 

390 ( 

391 "PmtId", 

392 OrderedDict( 

393 [ 

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

395 ] 

396 ), 

397 ), 

398 ( 

399 "Amt", 

400 OrderedDict( 

401 [ 

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

403 ] 

404 ), 

405 ), 

406 ( 

407 "UltmtDbtr", 

408 OrderedDict( 

409 [ 

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

411 ] 

412 ), 

413 ), 

414 ( 

415 "CdtrAgt", 

416 OrderedDict( 

417 [ 

418 ( 

419 "FinInstnId", 

420 OrderedDict( 

421 [ 

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

423 ] 

424 ), 

425 ), 

426 ] 

427 ), 

428 ), 

429 ( 

430 "Cdtr", 

431 OrderedDict( 

432 [ 

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

434 ] 

435 ), 

436 ), 

437 ( 

438 "CdtrAcct", 

439 OrderedDict( 

440 [ 

441 ( 

442 "Id", 

443 OrderedDict( 

444 [ 

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

446 ] 

447 ), 

448 ), 

449 ] 

450 ), 

451 ), 

452 rmt_inf, 

453 ] 

454 ), 

455 ), 

456 ] 

457 ), 

458 } 

459 ) 

460 

461 def render_to_element(self) -> Element: 

462 if not self.payments: 

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

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

465 pain = Element(self.pain_element_name) 

466 doc.append(pain) 

467 pain.append(self._grp_hdr()) 

468 for p in self.payments: 

469 assert isinstance(p, Pain001Payment) 

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

471 return doc 

472 

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

474 doc = doc or self.render_to_element() 

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

476 return xml_bytes 

477 

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

479 xml_bytes = xml_bytes or self.render_to_bytes() 

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

481 fp.write(xml_bytes) 

482 

483 

484class Pain002: 

485 """ 

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

487 """ 

488 

489 credit_datetime: datetime 

490 msg_id: str = "" 

491 original_msg_id: str = "" 

492 group_status: str = "" 

493 status_reason: str = "" 

494 

495 def __init__(self, file_content: bytes): 

496 self.data = xml_to_dict(file_content) 

497 

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

499 

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

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

502 if credit_datetime is None: 

503 raise ValidationError("CreDtTm missing") 

504 assert isinstance(credit_datetime, datetime) 

505 self.credit_datetime = credit_datetime 

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

507 

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

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

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

511 

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

513 if not self.group_status: 

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

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

516 if not self.status_reason: 

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

518 

519 if not self.msg_id: 

520 raise ValidationError("MsgId missing") 

521 if not self.original_msg_id: 

522 raise ValidationError("OrgnlMsgId missing") 

523 if not self.group_status: 

524 raise ValidationError("GrpSts missing") 

525 

526 def __str__(self): 

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

528 

529 @property 

530 def is_accepted(self): 

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

532 

533 @property 

534 def is_technically_accepted(self): 

535 return self.group_status == "ACTC" 

536 

537 @property 

538 def is_accepted_with_change(self): 

539 return self.group_status == "ACWC" 

540 

541 @property 

542 def is_partially_accepted(self): 

543 return self.group_status == "PART" 

544 

545 @property 

546 def is_pending(self): 

547 return self.group_status == "PDNG" 

548 

549 @property 

550 def is_rejected(self): 

551 return self.group_status == "RJCT"