Coverage for jbank/sepa.py: 89%

# Coverage for jbank/sepa.py : 89%

## 174 statements

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

2 from collections import OrderedDict

3 from datetime import datetime, date

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

5 from xml.etree import ElementTree as ET # noqa

6 from xml.etree.ElementTree import Element

7 from decimal import Decimal

8 import pytz

9 from django.core.exceptions import ValidationError

10 from django.utils.timezone import now

11 from django.utils.translation import gettext_lazy as _

12 from jutil.format import dec2

13 from jutil.parse import parse_datetime

14 from jutil.validators import iban_filter, iban_validator, iso_payment_reference_validator, \

15 fi_payment_reference_validator, ascii_filter, country_code_validator, bic_validator

16 from jutil.xml import xml_to_dict, _xml_element_set_data_r

17

18

19 PAIN001_REMITTANCE_INFO_MSG = 'M'

20 PAIN001_REMITTANCE_INFO_OCR = 'O'

21 PAIN001_REMITTANCE_INFO_OCR_ISO = 'I'

22

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

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

30

31

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

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

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

290 class 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'