Coverage for jbank/sepa.py : 88%

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
26PAIN001_REMITTANCE_INFO_MSG = "M"
27PAIN001_REMITTANCE_INFO_OCR = "O"
28PAIN001_REMITTANCE_INFO_OCR_ISO = "I"
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)
36PAIN001_REMITTANCE_INFO_VALUES = [t[0] for t in PAIN001_REMITTANCE_INFO_TYPE]
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
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
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)
92class Pain001:
93 """
94 Class for generating pain.001.001.03 SEPA payment XML files.
95 """
97 pain_element_name = "CstmrCdtTrfInitn"
98 tz_str = "Europe/Helsinki"
99 tz: Any = None
100 xml_declaration: Any = None
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)
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] = []
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)
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
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
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)
166 def _timestamp(self, t: datetime) -> str:
167 return self._local_time(t).isoformat()
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")
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
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))
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 )
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
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
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)
484class Pain002:
485 """
486 Class for parsing pain.002.001.03 SEPA payment status XML files.
487 """
489 credit_datetime: datetime
490 msg_id: str = ""
491 original_msg_id: str = ""
492 group_status: str = ""
493 status_reason: str = ""
495 def __init__(self, file_content: bytes):
496 self.data = xml_to_dict(file_content)
498 rpt = self.data.get("CstmrPmtStsRpt", {})
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")
508 grp_inf = rpt.get("OrgnlGrpInfAndSts", {})
509 self.original_msg_id = grp_inf.get("OrgnlMsgId")
510 pmt_inf = rpt.get("OrgnlPmtInfAndSts") or {}
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 ""
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")
526 def __str__(self):
527 return "{}: {} {} {}".format(self.msg_id, self.original_msg_id, self.group_status, self.status_reason)
529 @property
530 def is_accepted(self):
531 return self.group_status in ["ACCP", "ACSC", "ACSP"]
533 @property
534 def is_technically_accepted(self):
535 return self.group_status == "ACTC"
537 @property
538 def is_accepted_with_change(self):
539 return self.group_status == "ACWC"
541 @property
542 def is_partially_accepted(self):
543 return self.group_status == "PART"
545 @property
546 def is_pending(self):
547 return self.group_status == "PDNG"
549 @property
550 def is_rejected(self):
551 return self.group_status == "RJCT"