Coverage for jbank/sepa.py: 87%
195 statements
« prev ^ index » next coverage.py v7.2.2, created at 2023-03-27 13:36 +0700
« 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
27PAIN001_REMITTANCE_INFO_MSG = "M"
28PAIN001_REMITTANCE_INFO_OCR = "O"
29PAIN001_REMITTANCE_INFO_OCR_ISO = "I"
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)
37PAIN001_REMITTANCE_INFO_VALUES = [t[0] for t in PAIN001_REMITTANCE_INFO_TYPE]
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
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
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)
93class Pain001:
94 """
95 Class for generating pain.001.001.03 SEPA payment XML files.
96 """
98 pain_element_name = "CstmrCdtTrfInitn"
99 tz_str = "Europe/Helsinki"
100 tz: Any = None
101 xml_declaration: Any = None
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)
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] = []
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)
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
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
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)
165 def _timestamp(self, t: datetime) -> str:
166 return self._local_time(t).isoformat()
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")
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
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))
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 )
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
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
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)
486class Pain002:
487 """
488 Class for parsing pain.002.001.03 SEPA payment status XML files.
489 """
491 credit_datetime: datetime
492 msg_id: str = ""
493 original_msg_id: str = ""
494 group_status: str = ""
495 status_reason: str = ""
497 def __init__(self, file_content: bytes):
498 self.data = xml_to_dict(file_content)
500 rpt = self.data.get("CstmrPmtStsRpt", {})
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")
510 grp_inf = rpt.get("OrgnlGrpInfAndSts", {})
511 self.original_msg_id = grp_inf.get("OrgnlMsgId")
512 pmt_inf = rpt.get("OrgnlPmtInfAndSts") or {}
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 ""
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")
528 def __str__(self):
529 return "{}: {} {} {}".format(self.msg_id, self.original_msg_id, self.group_status, self.status_reason)
531 @property
532 def is_accepted(self):
533 return self.group_status in ["ACCP", "ACSC", "ACSP"]
535 @property
536 def is_technically_accepted(self):
537 return self.group_status == "ACTC"
539 @property
540 def is_accepted_with_change(self):
541 return self.group_status == "ACWC"
543 @property
544 def is_partially_accepted(self):
545 return self.group_status == "PART"
547 @property
548 def is_pending(self):
549 return self.group_status == "PDNG"
551 @property
552 def is_rejected(self):
553 return self.group_status == "RJCT"