Coverage for jbank/sepa.py : 89%

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 iban_filter, iban_validator, iso_payment_reference_validator, \
15 fi_payment_reference_validator, ascii_filter, country_code_validator, bic_validator
16from jutil.xml import xml_to_dict, _xml_element_set_data_r
19PAIN001_REMITTANCE_INFO_MSG = 'M'
20PAIN001_REMITTANCE_INFO_OCR = 'O'
21PAIN001_REMITTANCE_INFO_OCR_ISO = 'I'
23PAIN001_REMITTANCE_INFO_TYPE = (
24 (PAIN001_REMITTANCE_INFO_MSG, _('message')),
25 (PAIN001_REMITTANCE_INFO_OCR, _('OCR')),
26 (PAIN001_REMITTANCE_INFO_OCR_ISO, _('OCR/ISO')),
27)
29PAIN001_REMITTANCE_INFO_VALUES = [t[0] for t in PAIN001_REMITTANCE_INFO_TYPE]
32class Pain001Party:
33 def __init__(self, name: str, account: str, bic: str, org_id: str = '',
34 address_lines: Optional[Sequence[str]] = None, country_code: str = ''):
35 if address_lines is None:
36 address_lines = []
37 account = iban_filter(account)
38 iban_validator(account)
39 self.name = name
40 self.account = account
41 self.bic = bic
42 self.org_id = org_id
43 self.address_lines = address_lines
44 self.country_code = country_code
47class Pain001Payment:
48 def __init__(self, payment_id: Union[str, int], creditor: Pain001Party, amount: Decimal,
49 remittance_info: str, remittance_info_type: str, due_date: date):
50 self.payment_id = payment_id
51 self.creditor = creditor
52 self.amount = amount
53 self.remittance_info = remittance_info
54 self.remittance_info_type = remittance_info_type
55 self.due_date = due_date
57 def clean(self):
58 if not self.remittance_info:
59 raise ValidationError(_('pain001.remittance.info.missing'))
60 if self.remittance_info_type not in PAIN001_REMITTANCE_INFO_VALUES:
61 raise ValidationError(_('pain001.remittance.info.type.invalid'))
62 if self.remittance_info_type == PAIN001_REMITTANCE_INFO_MSG:
63 if not self.remittance_info:
64 raise ValidationError(_('Invalid payment reference: {}').format(self.remittance_info))
65 elif self.remittance_info_type == PAIN001_REMITTANCE_INFO_OCR:
66 fi_payment_reference_validator(self.remittance_info)
67 elif self.remittance_info_type == PAIN001_REMITTANCE_INFO_OCR_ISO:
68 iso_payment_reference_validator(self.remittance_info)
71class Pain001:
72 """
73 Class for generating pain.001.001.03 SEPA payment XML files.
74 """
76 pain_element_name = 'CstmrCdtTrfInitn'
77 tz_str = 'Europe/Helsinki'
78 tz: Any = None
80 def __init__(self, msg_id: str,
81 debtor_name: str,
82 debtor_account: str,
83 debtor_bic: str,
84 debtor_org_id: str,
85 debtor_address_lines: Sequence[str],
86 debtor_country_code: str):
87 if not debtor_org_id or len(debtor_org_id) < 5:
88 raise ValidationError({'debtor_org_id': _('invalid value')})
89 if not debtor_name or len(debtor_name) < 2:
90 raise ValidationError({'debtor_name': _('invalid value')})
91 if not debtor_address_lines:
92 raise ValidationError({'debtor_address_lines': _('invalid value')})
93 bic_validator(debtor_bic)
94 country_code_validator(debtor_country_code)
95 iban_validator(debtor_account)
97 self.msg_id = msg_id
98 self.debtor = Pain001Party(debtor_name, debtor_account, debtor_bic, debtor_org_id, debtor_address_lines, debtor_country_code)
99 self.payments: List[Pain001Payment] = []
101 def add_payment(self, payment_id,
102 creditor_name: str,
103 creditor_account: str,
104 creditor_bic: str,
105 amount: Decimal,
106 remittance_info: str,
107 remittance_info_type: str = PAIN001_REMITTANCE_INFO_MSG,
108 due_date: date = None,
109 ):
110 if not due_date:
111 due_date = self._local_time().date()
112 creditor = Pain001Party(creditor_name, creditor_account, creditor_bic)
113 p = Pain001Payment(payment_id, creditor, dec2(amount), remittance_info, remittance_info_type, due_date)
114 p.clean()
115 self.payments.append(p)
117 def _ctrl_sum(self) -> Decimal:
118 total = Decimal('0.00')
119 for p in self.payments:
120 assert isinstance(p, Pain001Payment)
121 total += p.amount
122 return total
124 def _append_simple(self, parent: Element, tag: str, value):
125 e = Element(tag)
126 e.text = str(value)
127 parent.append(e)
128 return e
130 def _local_time(self, t: Optional[datetime] = None) -> datetime:
131 if not t:
132 t = now()
133 if not self.tz:
134 self.tz = pytz.timezone(self.tz_str)
135 return t.astimezone(self.tz)
137 def _timestamp(self, t: datetime) -> str:
138 return self._local_time(t).isoformat()
140 @staticmethod
141 def _dict_to_element(doc: Dict[str, Any], value_key: str = '@', attribute_prefix: str = '@') -> Element:
142 if len(doc) != 1:
143 raise Exception('Invalid data dict for XML generation, document root must have single element')
144 for tag, data in doc.items():
145 el = Element(tag)
146 assert isinstance(el, Element)
147 _xml_element_set_data_r(el, data, value_key, attribute_prefix)
148 return el # pytype: disable=bad-return-type
149 return Element('empty')
151 def _grp_hdr(self) -> Element:
152 g = Element('GrpHdr')
153 self._append_simple(g, 'MsgId', self.msg_id)
154 self._append_simple(g, 'CreDtTm', self._timestamp(now()))
155 self._append_simple(g, 'NbOfTxs', len(self.payments))
156 self._append_simple(g, 'CtrlSum', self._ctrl_sum())
157 # self._append_simple(g, 'BtchBookg', 'true') # debit all at once
158 # self._append_simple(g, 'Grpg', 'MIXD')
159 g.append(self._dict_to_element({
160 'InitgPty': OrderedDict([
161 ('Nm', self.debtor.name),
162 ('PstlAdr', OrderedDict([
163 ('Ctry', self.debtor.country_code),
164 ('AdrLine', [{'@': al} for al in self.debtor.address_lines]),
165 ])),
166 ]),
167 }))
168 return g
170 def _pmt_inf(self, p: Pain001Payment) -> Element:
171 rmt_inf: Tuple[str, Any]
172 if p.remittance_info_type == PAIN001_REMITTANCE_INFO_MSG:
173 rmt_inf = ('RmtInf', OrderedDict([
174 ('Ustrd', p.remittance_info),
175 ]))
176 elif p.remittance_info_type == PAIN001_REMITTANCE_INFO_OCR:
177 rmt_inf = ('RmtInf', OrderedDict([
178 ('Strd', OrderedDict([
179 ('CdtrRefInf', OrderedDict([
180 ('Tp', OrderedDict([
181 ('CdOrPrtry', OrderedDict([
182 ('Cd', 'SCOR'),
183 ])),
184 ])),
185 ('Ref', p.remittance_info),
186 ])),
187 ])),
188 ]))
189 elif p.remittance_info_type == PAIN001_REMITTANCE_INFO_OCR_ISO:
190 rmt_inf = ('RmtInf', OrderedDict([
191 ('Strd', OrderedDict([
192 ('CdtrRefInf', OrderedDict([
193 ('Tp', OrderedDict([
194 ('CdOrPrtry', OrderedDict([
195 ('Cd', 'SCOR'),
196 ])),
197 ('Issr', 'ISO'),
198 ])),
199 ('Ref', p.remittance_info),
200 ])),
201 ])),
202 ]))
203 else:
204 raise ValidationError(_('Invalid remittance info type: {}').format(p.remittance_info_type))
206 return self._dict_to_element({
207 'PmtInf': OrderedDict([
208 ('PmtInfId', str(p.payment_id)),
209 ('PmtMtd', 'TRF'), # payment order
210 ('ReqdExctnDt', p.due_date.isoformat()),
211 ('Dbtr', OrderedDict([
212 ('Nm', self.debtor.name),
213 ('PstlAdr', OrderedDict([
214 ('Ctry', self.debtor.country_code),
215 ('AdrLine', [{'@': al} for al in self.debtor.address_lines]),
216 ])),
217 ('Id', OrderedDict([
218 ('OrgId', OrderedDict([
219 ('Othr', OrderedDict([
220 ('Id', self.debtor.org_id),
221 ('SchmeNm', OrderedDict([
222 ('Cd', 'BANK'),
223 ])),
224 ])),
225 ])),
226 ])),
227 ])),
228 ('DbtrAcct', OrderedDict([
229 ('Id', OrderedDict([
230 ('IBAN', self.debtor.account),
231 ])),
232 ])),
233 ('DbtrAgt', OrderedDict([
234 ('FinInstnId', OrderedDict([
235 ('BIC', self.debtor.bic),
236 ])),
237 ])),
238 ('ChrgBr', 'SLEV'), # FollowingService level
239 ('CdtTrfTxInf', OrderedDict([
240 ('PmtId', OrderedDict([
241 ('EndToEndId', str(p.payment_id)),
242 ])),
243 ('Amt', OrderedDict([
244 ('InstdAmt', {'@': str(p.amount), '@Ccy': 'EUR'}),
245 ])),
246 ('UltmtDbtr', OrderedDict([
247 ('Nm', self.debtor.name),
248 ])),
249 ('CdtrAgt', OrderedDict([
250 ('FinInstnId', OrderedDict([
251 ('BIC', p.creditor.bic),
252 ])),
253 ])),
254 ('Cdtr', OrderedDict([
255 ('Nm', ascii_filter(p.creditor.name)),
256 ])),
257 ('CdtrAcct', OrderedDict([
258 ('Id', OrderedDict([
259 ('IBAN', p.creditor.account),
260 ])),
261 ])),
262 rmt_inf,
263 ])),
264 ]),
265 })
267 def render_to_element(self) -> Element:
268 if not self.payments:
269 raise ValidationError('No payments in pain.001.001.03')
270 doc = Element('Document', xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03")
271 pain = Element(self.pain_element_name)
272 doc.append(pain)
273 pain.append(self._grp_hdr())
274 for p in self.payments:
275 assert isinstance(p, Pain001Payment)
276 pain.append(self._pmt_inf(p))
277 return doc
279 def render_to_bytes(self, doc: Optional[Element] = None) -> bytes:
280 doc = doc or self.render_to_element()
281 xml_bytes = ET.tostring(doc, encoding='utf-8', method='xml')
282 return xml_bytes
284 def render_to_file(self, filename: str, xml_bytes: Optional[bytes] = None):
285 xml_bytes = xml_bytes or self.render_to_bytes()
286 with open(filename, 'wb') as fp:
287 fp.write(xml_bytes)
290class Pain002:
291 """
292 Class for parsing pain.002.001.03 SEPA payment status XML files.
293 """
294 credit_datetime: datetime
295 msg_id: str = ''
296 original_msg_id: str = ''
297 group_status: str = ''
298 status_reason: str = ''
300 def __init__(self, file_content: bytes):
301 self.data = xml_to_dict(file_content)
303 rpt = self.data.get('CstmrPmtStsRpt', {})
305 grp_hdr = rpt.get('GrpHdr', {})
306 credit_datetime = parse_datetime(grp_hdr.get('CreDtTm'))
307 if credit_datetime is None:
308 raise ValidationError('CreDtTm missing')
309 assert isinstance(credit_datetime, datetime)
310 self.credit_datetime = credit_datetime
311 self.msg_id = grp_hdr.get('MsgId')
313 grp_inf = rpt.get('OrgnlGrpInfAndSts', {})
314 self.original_msg_id = grp_inf.get('OrgnlMsgId')
315 self.group_status = grp_inf.get('GrpSts')
316 self.status_reason = grp_inf.get('StsRsnInf', {}).get('Rsn', {}).get('Prtry', '')
318 if not self.msg_id:
319 raise ValidationError('MsgId missing')
320 if not self.original_msg_id:
321 raise ValidationError('OrgnlMsgId missing')
322 if not self.group_status:
323 raise ValidationError('GrpSts missing')
325 def __str__(self):
326 return '{}: {} {} {}'.format(self.msg_id, self.original_msg_id, self.group_status, self.status_reason)
328 @property
329 def is_accepted(self):
330 return self.group_status == 'ACCP'
332 @property
333 def is_rejected(self):
334 return self.group_status == 'RJCT'