Coverage for jbank/sepa.py: 88%

# Coverage for jbank/sepa.py : 88%

## 192 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# pylint: disable=too-many-arguments

2 from collections import OrderedDict

3 from datetime import datetime, date

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

5 from xml.etree import ElementTree as ET # noqa

6 from xml.etree.ElementTree import Element

7 from decimal import Decimal

8 import pytz

9 from django.core.exceptions import ValidationError

10 from django.utils.timezone import now

11 from django.utils.translation import gettext_lazy as _

12 from jutil.format import dec2

13 from jutil.parse import parse_datetime

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

23 from jutil.xml import xml_to_dict, _xml_element_set_data_r

24

25

26 PAIN001_REMITTANCE_INFO_MSG = "M"

27 PAIN001_REMITTANCE_INFO_OCR = "O"

28 PAIN001_REMITTANCE_INFO_OCR_ISO = "I"

29

30 PAIN001_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

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

37

38

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

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

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

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