Coverage for mt940/tags.py: 0%

167 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2024-05-04 05:32 +0000

1# vim: fileencoding=utf-8: 

2''' 

3 

4The MT940 format is a standard for bank account statements. It is used by 

5many banks in Europe and is based on the SWIFT MT940 format. 

6 

7The MT940 tags are: 

8 

9+---------+-----------------------------------------------------------------+ 

10| Tag | Description | 

11+=========+=================================================================+ 

12| `:13:` | Date/Time indication at which the report was created | 

13+---------+-----------------------------------------------------------------+ 

14| `:20:` | Transaction Reference Number | 

15+---------+-----------------------------------------------------------------+ 

16| `:21:` | Related Reference Number | 

17+---------+-----------------------------------------------------------------+ 

18| `:25:` | Account Identification | 

19+---------+-----------------------------------------------------------------+ 

20| `:28:` | Statement Number | 

21+---------+-----------------------------------------------------------------+ 

22| `:34:` | The floor limit for debit and credit | 

23+---------+-----------------------------------------------------------------+ 

24| `:60F:` | Opening Balance | 

25+---------+-----------------------------------------------------------------+ 

26| `:60M:` | Intermediate Balance | 

27+---------+-----------------------------------------------------------------+ 

28| `:60E:` | Closing Balance | 

29+---------+-----------------------------------------------------------------+ 

30| `:61:` | Statement Line | 

31+---------+-----------------------------------------------------------------+ 

32| `:62:` | Closing Balance | 

33+---------+-----------------------------------------------------------------+ 

34| `:62M:` | Intermediate Closing Balance | 

35+---------+-----------------------------------------------------------------+ 

36| `:62F:` | Final Closing Balance | 

37+---------+-----------------------------------------------------------------+ 

38| `:64:` | Available Balance | 

39+---------+-----------------------------------------------------------------+ 

40| `:65:` | Forward Available Balance | 

41+---------+-----------------------------------------------------------------+ 

42| `:86:` | Transaction Information | 

43+---------+-----------------------------------------------------------------+ 

44| `:90:` | Total number and amount of debit entries | 

45+---------+-----------------------------------------------------------------+ 

46| `:NS:` | Bank specific Non-swift extensions containing extra information | 

47+---------+-----------------------------------------------------------------+ 

48 

49Format 

50--------------------- 

51 

52Sources: 

53 

54.. _Swift for corporates: http://www.sepaforcorporates.com/\ 

55 swift-for-corporates/account-statement-mt940-file-format-overview/ 

56.. _Rabobank MT940: https://www.rabobank.nl/images/\ 

57 formaatbeschrijving_swift_bt940s_1_0_nl_rib_29539296.pdf 

58 

59 - `Swift for corporates`_ 

60 - `Rabobank MT940`_ 

61 

62The pattern for the tags use the following syntax: 

63 

64:: 

65 

66 [] = optional 

67 ! = fixed length 

68 a = Text 

69 x = Alphanumeric, seems more like text actually. Can include special 

70 characters (slashes) and whitespace as well as letters and numbers 

71 d = Numeric separated by decimal (usually comma) 

72 c = Code list value 

73 n = Numeric 

74''' 

75from __future__ import print_function 

76 

77import logging 

78import re 

79 

80try: 

81 import enum 

82except ImportError: # pragma: no cover 

83 import sys 

84 

85 print('MT940 requires the `enum34` package', file=sys.stderr) 

86 

87 class enum(object): 

88 @staticmethod 

89 def unique(*args, **kwargs): 

90 return [] 

91 

92 Enum = object 

93 

94from . import models 

95 

96logger = logging.getLogger(__name__) 

97 

98 

99class Tag(object): 

100 id = 0 

101 RE_FLAGS = re.IGNORECASE | re.VERBOSE | re.UNICODE 

102 scope = models.Transactions 

103 

104 def __init__(self): 

105 self.re = re.compile(self.pattern, self.RE_FLAGS) 

106 

107 def parse(self, transactions, value): 

108 match = self.re.match(value) 

109 if match: # pragma: no branch 

110 self.logger.debug( 

111 'matched (%d) %r against "%s", got: %s', 

112 len(value), value, self.pattern, 

113 match.groupdict() 

114 ) 

115 else: # pragma: no cover 

116 self.logger.error( 

117 'matching id=%s (len=%d) "%s" against\n %s', 

118 self.id, 

119 len(value), 

120 value, 

121 self.pattern 

122 ) 

123 

124 part_value = value 

125 for pattern in self.pattern.split('\n'): 

126 match = re.match(pattern, part_value, self.RE_FLAGS) 

127 if match: 

128 self.logger.info( 

129 'matched %r against %r, got: %s', 

130 pattern, match.group(0), 

131 match.groupdict() 

132 ) 

133 part_value = part_value[len(match.group(0)):] 

134 else: 

135 self.logger.error( 

136 'no match for %r against %r', 

137 pattern, part_value 

138 ) 

139 

140 raise RuntimeError( 

141 'Unable to parse %r from %r' % (self, value), 

142 self, value 

143 ) 

144 return match.groupdict() 

145 

146 def __call__(self, transactions, value): 

147 return value 

148 

149 def __new__(cls, *args, **kwargs): 

150 cls.name = cls.__name__ 

151 

152 words = re.findall('([A-Z][a-z]+)', cls.__name__) 

153 cls.slug = '_'.join(w.lower() for w in words) 

154 cls.logger = logger.getChild(cls.name) 

155 

156 return object.__new__(cls, *args, **kwargs) 

157 

158 def __hash__(self): 

159 return self.id 

160 

161 

162class DateTimeIndication(Tag): 

163 '''Date/Time indication at which the report was created 

164 

165 Pattern: 6!n4!n1! x4!n 

166 ''' 

167 id = 13 

168 pattern = r'''^ 

169 (?P<year>\d{2}) 

170 (?P<month>\d{2}) 

171 (?P<day>\d{2}) 

172 (?P<hour>\d{2}) 

173 (?P<minute>\d{2}) 

174 (\+(?P<offset>\d{4})|) 

175 ''' 

176 

177 def __call__(self, transactions, value): 

178 data = super(DateTimeIndication, self).__call__(transactions, value) 

179 return { 

180 'date': models.DateTime(**data) 

181 } 

182 

183 

184class TransactionReferenceNumber(Tag): 

185 

186 '''Transaction reference number 

187 

188 Pattern: 16x 

189 ''' 

190 id = 20 

191 pattern = r'(?P<transaction_reference>.{0,16})' 

192 

193 

194class RelatedReference(Tag): 

195 

196 '''Related reference 

197 

198 Pattern: 16x 

199 ''' 

200 id = 21 

201 pattern = r'(?P<related_reference>.{0,16})' 

202 

203 

204class AccountIdentification(Tag): 

205 

206 '''Account identification 

207 

208 Pattern: 35x 

209 ''' 

210 id = 25 

211 pattern = r'(?P<account_identification>.{0,35})' 

212 

213 

214class StatementNumber(Tag): 

215 

216 '''Statement number / sequence number 

217 

218 Pattern: 5n[/5n] 

219 ''' 

220 id = 28 

221 pattern = r''' 

222 (?P<statement_number>\d{1,5}) # 5n 

223 (?:/?(?P<sequence_number>\d{1,5}))? # [/5n] 

224 $''' 

225 

226 

227class FloorLimitIndicator(Tag): 

228 '''Floor limit indicator 

229 indicates the minimum value reported for debit and credit amounts 

230 

231 Pattern: :34F:GHSC0,00 

232 ''' 

233 id = 34 

234 pattern = r'''^ 

235 (?P<currency>[A-Z]{3}) # 3!a Currency 

236 (?P<status>[DC ]?) # 2a Debit/Credit Mark 

237 (?P<amount>[0-9,]{0,16}) # 15d Amount (includes decimal sign, so 16) 

238 $''' 

239 

240 def __call__(self, transactions, value): 

241 data = super(FloorLimitIndicator, self).__call__(transactions, value) 

242 if data['status']: 

243 return { 

244 data['status'].lower() + '_floor_limit': models.Amount(**data) 

245 } 

246 

247 data_d = data.copy() 

248 data_c = data.copy() 

249 data_d.update({'status': 'D'}) 

250 data_c.update({'status': 'C'}) 

251 return { 

252 'd_floor_limit': models.Amount(**data_d), 

253 'c_floor_limit': models.Amount(**data_c) 

254 } 

255 

256 

257class NonSwift(Tag): 

258 

259 '''Non-swift extension for MT940 containing extra information. The 

260 actual definition is not consistent between banks so the current 

261 implementation is a tad limited. Feel free to extend the implementation 

262 and create a pull request with a better version :) 

263 

264 It seems this could be anything so we'll have to be flexible about it. 

265 

266 Pattern: `2!n35x | *x` 

267 ''' 

268 

269 class scope(models.Transaction, models.Transactions): 

270 pass 

271 

272 id = 'NS' 

273 

274 pattern = r''' 

275 (?P<non_swift> 

276 ( 

277 (\d{2}.{0,}) 

278 (\n\d{2}.{0,})* 

279 )|( 

280 [^\n]* 

281 ) 

282 ) 

283 $''' 

284 sub_pattern = r''' 

285 (?P<ns_id>\d{2})(?P<ns_data>.{0,}) 

286 ''' 

287 sub_pattern_m = re.compile( 

288 sub_pattern, 

289 re.IGNORECASE | re.VERBOSE | re.UNICODE 

290 ) 

291 

292 def __call__(self, transactions, value): 

293 text = [] 

294 data = value['non_swift'] 

295 for line in data.split('\n'): 

296 frag = self.sub_pattern_m.match(line) 

297 if frag and frag.group(2): 

298 ns = frag.groupdict() 

299 value['non_swift_' + ns['ns_id']] = ns['ns_data'] 

300 text.append(ns['ns_data']) 

301 elif len(text) and text[-1]: 

302 text.append('') 

303 elif line.strip(): 

304 text.append(line.strip()) 

305 value['non_swift_text'] = '\n'.join(text) 

306 value['non_swift'] = data 

307 return value 

308 

309 

310class BalanceBase(Tag): 

311 

312 '''Balance base 

313 

314 Pattern: 1!a6!n3!a15d 

315 ''' 

316 pattern = r'''^ 

317 (?P<status>[DC]) # 1!a Debit/Credit 

318 (?P<year>\d{2}) # 6!n Value Date (YYMMDD) 

319 (?P<month>\d{2}) 

320 (?P<day>\d{2}) 

321 (?P<currency>.{3}) # 3!a Currency 

322 (?P<amount>[0-9,]{0,16}) # 15d Amount (includes decimal sign, so 16) 

323 ''' 

324 

325 def __call__(self, transactions, value): 

326 data = super(BalanceBase, self).__call__(transactions, value) 

327 data['amount'] = models.Amount(**data) 

328 data['date'] = models.Date(**data) 

329 return { 

330 self.slug: models.Balance(**data) 

331 } 

332 

333 

334class OpeningBalance(BalanceBase): 

335 id = 60 

336 

337 

338class FinalOpeningBalance(BalanceBase): 

339 id = '60F' 

340 

341 

342class IntermediateOpeningBalance(BalanceBase): 

343 id = '60M' 

344 

345 

346class Statement(Tag): 

347 

348 ''' 

349 

350 The MT940 Tag 61 provides information about a single transaction that 

351 has taken place on the account. Each transaction is identified by a 

352 unique transaction reference number (Tag 20) and is described in the 

353 Statement Line (Tag 61). 

354 

355 Pattern: 6!n[4!n]2a[1!a]15d1!a3!c23x[//16x] 

356 

357 The fields are: 

358 

359 - `value_date`: transaction date (YYMMDD) 

360 - `entry_date`: Optional 4-digit month value and 2-digit day value of 

361 the entry date (MMDD) 

362 - `funds_code`: Optional 1-character code indicating the funds type ( 

363 the third character of the currency code if needed) 

364 - `amount`: 15-digit value of the transaction amount, including commas 

365 for decimal separation 

366 - `transaction_type`: Optional 4-character transaction type 

367 identification code starting with a letter followed by alphanumeric 

368 characters and spaces 

369 - `customer_reference`: Optional 16-character customer reference, 

370 excluding any bank reference 

371 - `bank_reference`: Optional 23-character bank reference starting with 

372 "//" 

373 - `supplementary_details`: Optional 34-character supplementary details 

374 about the transaction. 

375 

376 The Tag 61 can occur multiple times within an MT940 file, with each 

377 occurrence representing a different transaction. 

378 

379 ''' 

380 id = 61 

381 scope = models.Transaction 

382 pattern = r'''^ 

383 (?P<year>\d{2}) # 6!n Value Date (YYMMDD) 

384 (?P<month>\d{2}) 

385 (?P<day>\d{2}) 

386 (?P<entry_month>\d{2})? # [4!n] Entry Date (MMDD) 

387 (?P<entry_day>\d{2})? 

388 (?P<status>R?[DC]) # 2a Debit/Credit Mark 

389 (?P<funds_code>[A-Z])? # [1!a] Funds Code (3rd character of the currency 

390 # code, if needed) 

391 [\n ]? # apparently some banks (sparkassen) incorporate newlines here 

392 # cuscal can also send a space here as well 

393 (?P<amount>[\d,]{1,15}) # 15d Amount 

394 (?P<id>[A-Z][A-Z0-9 ]{3})? # 1!a3!c Transaction Type Identification Code 

395 # We need the (slow) repeating negative lookahead to search for // so we 

396 # don't acciddntly include the bank reference in the customer reference. 

397 (?P<customer_reference>((?!//)[^\n]){0,16}) # 16x Customer Reference 

398 (//(?P<bank_reference>.{0,23}))? # [//23x] Bank Reference 

399 (\n?(?P<extra_details>.{0,34}))? # [34x] Supplementary Details 

400 $''' 

401 

402 def __call__(self, transactions, value): 

403 data = super(Statement, self).__call__(transactions, value) 

404 data.setdefault('currency', transactions.currency) 

405 

406 data['amount'] = models.Amount(**data) 

407 date = data['date'] = models.Date(**data) 

408 

409 if data.get('entry_day') and data.get('entry_month'): 

410 entry_date = data['entry_date'] = models.Date( 

411 day=data.get('entry_day'), 

412 month=data.get('entry_month'), 

413 year=str(data['date'].year), 

414 ) 

415 

416 if date > entry_date and (date - entry_date).days >= 330: 

417 year = 1 

418 elif entry_date > date and (entry_date - date).days >= 330: 

419 year = -1 

420 else: 

421 year = 0 

422 

423 data['guessed_entry_date'] = models.Date( 

424 day=entry_date.day, 

425 month=entry_date.month, 

426 year=entry_date.year + year, 

427 ) 

428 

429 return data 

430 

431 

432class StatementASNB(Statement): 

433 '''StatementASNB 

434 

435 From: https://www.sepaforcorporates.com/swift-for-corporates 

436 

437 Pattern: 6!n[4!n]2a[1!a]15d1!a3!c16x[//16x] 

438 [34x] 

439 

440 But ASN bank puts the IBAN in the customer reference, which is acording to 

441 Wikipedia at most 34 characters. 

442 

443 So this is the new pattern: 

444 

445 Pattern: 6!n[4!n]2a[1!a]15d1!a3!c34x[//16x] 

446 [34x] 

447 ''' 

448 pattern = r'''^ 

449 (?P<year>\d{2}) # 6!n Value Date (YYMMDD) 

450 (?P<month>\d{2}) 

451 (?P<day>\d{2}) 

452 (?P<entry_month>\d{2})? # [4!n] Entry Date (MMDD) 

453 (?P<entry_day>\d{2})? 

454 (?P<status>[A-Z]?[DC]) # 2a Debit/Credit Mark 

455 (?P<funds_code>[A-Z])? # [1!a] Funds Code (3rd character of the currency 

456 # code, if needed) 

457 \n? # apparently some banks (sparkassen) incorporate newlines here 

458 (?P<amount>[\d,]{1,15}) # 15d Amount 

459 (?P<id>[A-Z][A-Z0-9 ]{3})? # 1!a3!c Transaction Type Identification Code 

460 (?P<customer_reference>.{0,34}) # 34x Customer Reference 

461 (//(?P<bank_reference>.{0,16}))? # [//16x] Bank Reference 

462 (\n?(?P<extra_details>.{0,34}))? # [34x] Supplementary Details 

463 $''' 

464 

465 def __call__(self, transactions, value): 

466 return super(StatementASNB, self).__call__(transactions, value) 

467 

468 

469class ClosingBalance(BalanceBase): 

470 id = 62 

471 

472 

473class IntermediateClosingBalance(ClosingBalance): 

474 id = '62M' 

475 

476 

477class FinalClosingBalance(ClosingBalance): 

478 id = '62F' 

479 

480 

481class AvailableBalance(BalanceBase): 

482 id = 64 

483 

484 

485class ForwardAvailableBalance(BalanceBase): 

486 id = 65 

487 

488 

489class TransactionDetails(Tag): 

490 

491 '''Transaction details 

492 

493 Pattern: 6x65x 

494 ''' 

495 id = 86 

496 scope = models.Transaction 

497 pattern = r''' 

498 (?P<transaction_details>(([\s\S]{0,65}\r?\n?){0,8}[\s\S]{0,65})) 

499 ''' 

500 

501 

502class SumEntries(Tag): 

503 '''Number and Sum of debit Entries 

504 

505 ''' 

506 

507 id = 90 

508 pattern = r'''^ 

509 (?P<number>\d*) 

510 (?P<currency>.{3}) # 3!a Currency 

511 (?P<amount>[\d,]{1,15}) # 15d Amount 

512 ''' 

513 

514 def __call__(self, transactions, value): 

515 data = super(SumEntries, self).__call__(transactions, value) 

516 

517 data['status'] = self.status 

518 return { 

519 self.slug: models.SumAmount(**data) 

520 } 

521 

522 

523class SumDebitEntries(SumEntries): 

524 status = 'D' 

525 id = '90D' 

526 

527 

528class SumCreditEntries(SumEntries): 

529 status = 'C' 

530 id = '90C' 

531 

532 

533@enum.unique 

534class Tags(enum.Enum): 

535 DATE_TIME_INDICATION = DateTimeIndication() 

536 TRANSACTION_REFERENCE_NUMBER = TransactionReferenceNumber() 

537 RELATED_REFERENCE = RelatedReference() 

538 ACCOUNT_IDENTIFICATION = AccountIdentification() 

539 STATEMENT_NUMBER = StatementNumber() 

540 OPENING_BALANCE = OpeningBalance() 

541 INTERMEDIATE_OPENING_BALANCE = IntermediateOpeningBalance() 

542 FINAL_OPENING_BALANCE = FinalOpeningBalance() 

543 STATEMENT = Statement() 

544 CLOSING_BALANCE = ClosingBalance() 

545 INTERMEDIATE_CLOSING_BALANCE = IntermediateClosingBalance() 

546 FINAL_CLOSING_BALANCE = FinalClosingBalance() 

547 AVAILABLE_BALANCE = AvailableBalance() 

548 FORWARD_AVAILABLE_BALANCE = ForwardAvailableBalance() 

549 TRANSACTION_DETAILS = TransactionDetails() 

550 FLOOR_LIMIT_INDICATOR = FloorLimitIndicator() 

551 NON_SWIFT = NonSwift() 

552 SUM_ENTRIES = SumEntries() 

553 SUM_DEBIT_ENTRIES = SumDebitEntries() 

554 SUM_CREDIT_ENTRIES = SumCreditEntries() 

555 

556 

557TAG_BY_ID = {t.value.id: t.value for t in Tags}