Coverage for jbank/helpers.py: 70%

# Coverage for jbank/helpers.py : 70%

## 170 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 import logging

2 import os

3 from datetime import datetime, date

4 from os.path import basename

5 from typing import Any, Tuple, Optional, List

6 import pytz

7 from django.conf import settings

8 from django.core.exceptions import ValidationError

9 from django.core.files import File

10 from django.db import transaction, models

11 from django.utils.timezone import now

12 from django.utils.translation import gettext_lazy as _

13 from jacc.models import Account, AccountType, EntryType

14 from jbank.models import (

15 Statement,

16 StatementRecord,

17 StatementRecordSepaInfo,

18 ReferencePaymentBatch,

19 ReferencePaymentRecord,

20 StatementFile,

21 ReferencePaymentBatchFile,

22 Payout,

23 PayoutStatus,

24 PAYOUT_PAID,

25)

26 from jbank.sepa import Pain002

27 import re

28 from lxml import etree, objectify # type: ignore # pytype: disable=import-error

29 from jutil.parse import parse_datetime

30 from jutil.format import strip_media_root, is_media_full_path

31

32 ASSIGNABLE_STATEMENT_HEADER_FIELDS = (

33 "account_number",

34 "statement_number",

35 "begin_date",

36 "end_date",

37 "record_date",

38 "customer_identifier",

39 "begin_balance_date",

40 "begin_balance",

41 "record_count",

42 "currency_code",

43 "account_name",

44 "account_limit",

45 "owner_name",

46 "contact_info_1",

47 "contact_info_2",

48 "bank_specific_info_1",

49 "iban",

50 "bic",

51)

52

53 MESSAGE_STATEMENT_RECORD_FIELDS = ("messages", "client_messages", "bank_messages")

54

55 ASSIGNABLE_STATEMENT_RECORD_FIELDS = (

56 "record_date",

57 "value_date",

58 "paid_date",

59 "record_number",

60 "archive_identifier",

61 "entry_type",

62 "record_code",

63 "record_description",

64 "amount",

65 "receipt_code",

66 "delivery_method",

67 "name",

68 "name_source",

69 "recipient_account_number",

70 "recipient_account_number_changed",

71 "remittance_info",

72)

73

74 ASSIGNABLE_STATEMENT_RECORD_SEPA_INFO_FIELDS = (

75 "reference",

76 "iban_account_number",

77 "bic_code",

78 "recipient_name_detail",

79 "payer_name_detail",

80 "identifier",

81 "archive_identifier",

82)

83

84 ASSIGNABLE_REFERENCE_PAYMENT_BATCH_HEADER_FIELDS = (

85 "record_date",

86 "institution_identifier",

87 "service_identifier",

88 "currency_identifier",

89)

90

91 ASSIGNABLE_REFERENCE_PAYMENT_RECORD_FIELDS = (

92 "record_type",

93 "account_number",

94 "record_date",

95 "paid_date",

96 "archive_identifier",

97 "remittance_info",

98 "payer_name",

99 "currency_identifier",

100 "name_source",

101 "amount",

102 "correction_identifier",

103 "delivery_method",

104 "receipt_code",

105)

106

107

108 logger = logging.getLogger(__name__)

109

110

111@transaction.atomic # noqa

112 def create_statement(statement_data: dict, name: str, file: StatementFile, **kw) -> Statement: # noqa

113 """

114 Creates Statement from statement data parsed by parse_tiliote_statements()

115 :param statement_data: See parse_tiliote_statements

116 :param name: File name of the account statement

117 :param file: Source statement file

118 :return: Statement

119 """

120 if "header" not in statement_data or not statement_data["header"]:

121 raise ValidationError(

122 "Invalid header field in statement data {}: {}".format(name, statement_data.get("header"))

123 )

124 header = statement_data["header"]

125

126 account_number = header["account_number"]

127 if not account_number:

128 raise ValidationError("{name}: ".format(name=name) + _("account.not.found").format(account_number=""))

129 accounts = list(Account.objects.filter(name=account_number))

130 if len(accounts) != 1:

131 raise ValidationError(

132 "{name}: ".format(name=name) + _("account.not.found").format(account_number=account_number)

133 )

134 account = accounts[0]

135 assert isinstance(account, Account)

136

137 if Statement.objects.filter(name=name, account=account).first():

138 raise ValidationError("Bank account {} statement {} of processed already".format(account_number, name))

139 stm = Statement(name=name, account=account, file=file)

140 for k in ASSIGNABLE_STATEMENT_HEADER_FIELDS:

141 if k in header:

142 setattr(stm, k, header[k])

143 # pprint(statement_data['header'])

144 for k, v in kw.items():

145 setattr(stm, k, v)

146 stm.full_clean()

147 stm.save()

148

149 if EntryType.objects.filter(code=settings.E_BANK_DEPOSIT).count() == 0:

150 raise ValidationError(

151 _("entry.type.missing") + " ({}): {}".format("settings.E_BANK_DEPOSIT", settings.E_BANK_DEPOSIT)

152 )

153 if EntryType.objects.filter(code=settings.E_BANK_WITHDRAW).count() == 0:

154 raise ValidationError(

155 _("entry.type.missing") + " ({}): {}".format("settings.E_BANK_WITHDRAW", settings.E_BANK_WITHDRAW)

156 )

157 entry_types = {

158 "1": EntryType.objects.get(code=settings.E_BANK_DEPOSIT),

159 "2": EntryType.objects.get(code=settings.E_BANK_WITHDRAW),

160 }

161

162 for rec_data in statement_data["records"]:

163 line_number = rec_data["line_number"]

164 e_type = entry_types.get(rec_data["entry_type"])

165 rec = StatementRecord(statement=stm, account=account, type=e_type, line_number=line_number)

166 for k in ASSIGNABLE_STATEMENT_RECORD_FIELDS:

167 if k in rec_data:

168 setattr(rec, k, rec_data[k])

169 for k in MESSAGE_STATEMENT_RECORD_FIELDS:

170 if k in rec_data:

171 setattr(rec, k, "\n".join(rec_data[k]))

172 rec.full_clean()

173 rec.save()

174

175 if "sepa" in rec_data:

176 sepa_info_data = rec_data["sepa"]

177 sepa_info = StatementRecordSepaInfo(record=rec)

178 for k in ASSIGNABLE_STATEMENT_RECORD_SEPA_INFO_FIELDS:

179 if k in sepa_info_data:

180 setattr(sepa_info, k, sepa_info_data[k])

181 # pprint(rec_data['sepa'])

182 sepa_info.full_clean()

183 sepa_info.save()

184

185 return stm

186

187

188@transaction.atomic

189 def create_reference_payment_batch(

190 batch_data: dict, name: str, file: ReferencePaymentBatchFile, **kw

191) -> ReferencePaymentBatch:

192 """

193 Creates ReferencePaymentBatch from data parsed by parse_svm_batches()

194 :param batch_data: See parse_svm_batches

195 :param name: File name of the batch file

196 :return: ReferencePaymentBatch

197 """

198 if ReferencePaymentBatch.objects.exclude(file=file).filter(name=name).first():

199 raise ValidationError("Reference payment batch file {} already exists".format(name))

200

201 if "header" not in batch_data or not batch_data["header"]:

202 raise ValidationError(

203 "Invalid header field in reference payment batch data {}: {}".format(name, batch_data.get("header"))

204 )

205 header = batch_data["header"]

206

207 batch = ReferencePaymentBatch(name=name, file=file)

208 for k in ASSIGNABLE_REFERENCE_PAYMENT_BATCH_HEADER_FIELDS:

209 if k in header:

210 setattr(batch, k, header[k])

211 # pprint(statement_data['header'])

212 for k, v in kw.items():

213 setattr(batch, k, v)

214 batch.full_clean()

215 batch.save()

216 e_type = EntryType.objects.get(code=settings.E_BANK_REFERENCE_PAYMENT)

217

218 for rec_data in batch_data["records"]:

219 line_number = rec_data["line_number"]

220 account_number = rec_data["account_number"]

221 if not account_number:

222 raise ValidationError("{name}: ".format(name=name) + _("account.not.found").format(account_number=""))

223 accounts = list(Account.objects.filter(name=account_number))

224 if len(accounts) != 1:

225 raise ValidationError(

226 "{name}: ".format(name=name) + _("account.not.found").format(account_number=account_number)

227 )

228 account = accounts[0]

229 assert isinstance(account, Account)

230

231 rec = ReferencePaymentRecord(batch=batch, account=account, type=e_type, line_number=line_number)

232 for k in ASSIGNABLE_REFERENCE_PAYMENT_RECORD_FIELDS:

233 if k in rec_data:

234 setattr(rec, k, rec_data[k])

235 # pprint(rec_data)

236 rec.full_clean()

237 rec.save()

238

239 return batch

240

241

242 def get_or_create_bank_account_entry_types() -> List[EntryType]:

243 e_type_codes = [

244 settings.E_BANK_DEPOSIT,

245 settings.E_BANK_WITHDRAW,

246 settings.E_BANK_REFERENCE_PAYMENT,

247 settings.E_BANK_REFUND,

248 settings.E_BANK_PAYOUT,

249 ]

250 e_types: List[EntryType] = []

251 for code in e_type_codes:

252 e_type = EntryType.objects.get_or_create(

253 code=code,

254 defaults={

255 "identifier": code,

256 "name": code,

257 "is_settlement": True,

258 "is_payment": code in [settings.E_BANK_DEPOSIT, settings.E_BANK_REFERENCE_PAYMENT],

259 },

260 )[0]

261 e_types.append(e_type)

262 return e_types

263

264

265 def get_or_create_bank_account(account_number: str, currency: str = "EUR") -> Account:

266 a_type = AccountType.objects.get_or_create(

267 code=settings.ACCOUNT_BANK_ACCOUNT, is_asset=True, defaults={"name": _("bank account")}

268 )[0]

269 acc, created = Account.objects.get_or_create(name=account_number, type=a_type, currency=currency)

270 if created:

271 get_or_create_bank_account_entry_types()

272 return acc

273

274

275 def process_pain002_file_content(bcontent: bytes, filename: str, created: Optional[datetime] = None):

276 if not created:

277 created = now()

278 s = Pain002(bcontent)

279 p = Payout.objects.filter(msg_id=s.original_msg_id).first()

280

281 ps = PayoutStatus(

282 payout=p,

283 file_name=basename(filename),

284 file_path=strip_media_root(filename),

285 msg_id=s.msg_id,

286 original_msg_id=s.original_msg_id,

287 group_status=s.group_status,

288 status_reason=s.status_reason[:255],

289 created=created,

290 )

291 ps.full_clean()

292 fields = (

293 "payout",

294 "file_name",

295 "response_code",

296 "response_text",

297 "msg_id",

298 "original_msg_id",

299 "group_status",

300 "status_reason",

301 )

302 params = {}

303 for k in fields:

304 params[k] = getattr(ps, k)

305 ps_old = PayoutStatus.objects.filter(**params).first()

306 if ps_old:

307 ps = ps_old

308 else:

309 ps.save()

310 logger.info("%s status updated %s", p, ps)

311 if p:

312 if ps.is_accepted:

313 p.state = PAYOUT_PAID

314 p.paid_date = s.credit_datetime

315 p.save(update_fields=["state", "paid_date"])

316 logger.info("%s marked as paid %s", p, ps)

317 return ps

318

319

320 def make_msg_id() -> str:

321 return re.sub(r"[^\d]", "", now().isoformat())[:-4]

322

323

324 def validate_xml(content: bytes, xsd_file_name: str):

325 """

326 Validates XML using XSD

327 """

328 schema = etree.XMLSchema(file=xsd_file_name)

329 parser = objectify.makeparser(schema=schema)

330 objectify.fromstring(content, parser)

331

332

333 def parse_start_and_end_date(tz: Any, **options) -> Tuple[Optional[date], Optional[date]]:

334 start_date = None

335 end_date = None

336 time_now = now().astimezone(tz if tz else pytz.utc)

337 if options["start_date"]:

338 if options["start_date"] == "today":

339 start_date = time_now.date()

340 else:

341 start_date = parse_datetime(options["start_date"]).date() # type: ignore

342 end_date = start_date

343 if options["end_date"]:

344 if options["end_date"] == "today":

345 end_date = time_now.date()

346 else:

347 end_date = parse_datetime(options["end_date"]).date() # type: ignore

348 return start_date, end_date

349

350

351 def save_or_store_media(file: models.FileField, filename: str):

352 """

353 Saves FileField filename as relative path if it's under MEDIA_ROOT.

354 Otherwise writes file under media root.

355 """

356 if is_media_full_path(filename):

357 file.name = strip_media_root(filename) # type: ignore

358 else:

359 with open(filename, "rb") as fp:

360 plain_filename = os.path.basename(filename)

361 file.save(plain_filename, File(fp)) # type: ignore # noqa