Coverage for jbank/sepa.py: 87%

1# pylint: disable=too-many-arguments

2 import sys

3 from collections import OrderedDict

4 from datetime import datetime, date

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

6 from xml.etree import ElementTree as ET # noqa

7 from xml.etree.ElementTree import Element

8 from decimal import Decimal

9 import pytz

10 from django.core.exceptions import ValidationError

11 from django.utils.timezone import now

12 from django.utils.translation import gettext_lazy as _

13 from jutil.format import dec2

14 from jutil.parse import parse_datetime

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

24 from jutil.xml import xml_to_dict, _xml_element_set_data_r

25

26

27 PAIN001_REMITTANCE_INFO_MSG = "M"

28 PAIN001_REMITTANCE_INFO_OCR = "O"

29 PAIN001_REMITTANCE_INFO_OCR_ISO = "I"

30

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

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

38

39

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

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

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

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