Hide keyboard shortcuts

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 

17 

18 

19PAIN001_REMITTANCE_INFO_MSG = 'M' 

20PAIN001_REMITTANCE_INFO_OCR = 'O' 

21PAIN001_REMITTANCE_INFO_OCR_ISO = 'I' 

22 

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) 

28 

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

30 

31 

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 

45 

46 

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 

56 

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) 

69 

70 

71class Pain001: 

72 """ 

73 Class for generating pain.001.001.03 SEPA payment XML files. 

74 """ 

75 

76 pain_element_name = 'CstmrCdtTrfInitn' 

77 tz_str = 'Europe/Helsinki' 

78 tz: Any = None 

79 

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) 

96 

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] = [] 

100 

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) 

116 

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 

123 

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 

129 

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) 

136 

137 def _timestamp(self, t: datetime) -> str: 

138 return self._local_time(t).isoformat() 

139 

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') 

150 

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 

169 

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

205 

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 }) 

266 

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 

278 

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 

283 

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) 

288 

289 

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 = '' 

299 

300 def __init__(self, file_content: bytes): 

301 self.data = xml_to_dict(file_content) 

302 

303 rpt = self.data.get('CstmrPmtStsRpt', {}) 

304 

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') 

312 

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', '') 

317 

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') 

324 

325 def __str__(self): 

326 return '{}: {} {} {}'.format(self.msg_id, self.original_msg_id, self.group_status, self.status_reason) 

327 

328 @property 

329 def is_accepted(self): 

330 return self.group_status == 'ACCP' 

331 

332 @property 

333 def is_rejected(self): 

334 return self.group_status == 'RJCT'