Coverage for mt940/tags.py: 0%
166 statements
« prev ^ index » next coverage.py v7.6.0, created at 2025-02-23 05:07 +0000
« prev ^ index » next coverage.py v7.6.0, created at 2025-02-23 05:07 +0000
1# vim: fileencoding=utf-8:
2'''
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.
7The MT940 tags are:
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+---------+-----------------------------------------------------------------+
49Format
50---------------------
52Sources:
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
59 - `Swift for corporates`_
60 - `Rabobank MT940`_
62The pattern for the tags use the following syntax:
64::
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
77import logging
78import re
80try:
81 import enum
82except ImportError: # pragma: no cover
83 import sys
85 print('MT940 requires the `enum34` package', file=sys.stderr)
87 class enum(object):
88 @staticmethod
89 def unique(*args, **kwargs):
90 return []
92 Enum = object
94from . import models
96logger = logging.getLogger(__name__)
99class Tag(object):
100 id = 0
101 RE_FLAGS = re.IGNORECASE | re.VERBOSE | re.UNICODE
102 scope = models.Transactions
104 def __init__(self):
105 self.re = re.compile(self.pattern, self.RE_FLAGS)
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 )
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 )
140 raise RuntimeError(
141 'Unable to parse %r from %r' % (self, value),
142 self, value
143 )
144 return match.groupdict()
146 def __call__(self, transactions, value):
147 return value
149 def __new__(cls, *args, **kwargs):
150 cls.name = cls.__name__
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)
156 return object.__new__(cls, *args, **kwargs)
158 def __hash__(self):
159 return self.id
162class DateTimeIndication(Tag):
163 '''Date/Time indication at which the report was created
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 '''
177 def __call__(self, transactions, value):
178 data = super(DateTimeIndication, self).__call__(transactions, value)
179 return {
180 'date': models.DateTime(**data)
181 }
184class TransactionReferenceNumber(Tag):
186 '''Transaction reference number
188 Pattern: 16x
189 '''
190 id = 20
191 pattern = r'(?P<transaction_reference>.{0,16})'
194class RelatedReference(Tag):
196 '''Related reference
198 Pattern: 16x
199 '''
200 id = 21
201 pattern = r'(?P<related_reference>.{0,16})'
204class AccountIdentification(Tag):
206 '''Account identification
208 Pattern: 35x
209 '''
210 id = 25
211 pattern = r'(?P<account_identification>.{0,35})'
214class StatementNumber(Tag):
216 '''Statement number / sequence number
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 $'''
227class FloorLimitIndicator(Tag):
228 '''Floor limit indicator
229 indicates the minimum value reported for debit and credit amounts
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 $'''
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 }
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 }
257class NonSwift(Tag):
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 :)
264 It seems this could be anything so we'll have to be flexible about it.
266 Pattern: `2!n35x | *x`
267 '''
269 class scope(models.Transaction, models.Transactions):
270 pass
272 id = 'NS'
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 )
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
310class BalanceBase(Tag):
312 '''Balance base
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 '''
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 }
334class OpeningBalance(BalanceBase):
335 id = 60
338class FinalOpeningBalance(BalanceBase):
339 id = '60F'
342class IntermediateOpeningBalance(BalanceBase):
343 id = '60M'
346class Statement(Tag):
348 '''
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).
355 Pattern: 6!n[4!n]2a[1!a]15d1!a3!c23x[//16x]
357 The fields are:
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.
376 The Tag 61 can occur multiple times within an MT940 file, with each
377 occurrence representing a different transaction.
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 $'''
402 def __call__(self, transactions, value):
403 data = super(Statement, self).__call__(transactions, value)
404 data.setdefault('currency', transactions.currency)
406 data['amount'] = models.Amount(**data)
407 date = data['date'] = models.Date(**data)
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 )
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
423 data['guessed_entry_date'] = models.Date(
424 day=entry_date.day,
425 month=entry_date.month,
426 year=entry_date.year + year,
427 )
429 return data
432class StatementASNB(Statement):
433 '''StatementASNB
435 From: https://www.sepaforcorporates.com/swift-for-corporates
437 Pattern: 6!n[4!n]2a[1!a]15d1!a3!c16x[//16x]
438 [34x]
440 But ASN bank puts the IBAN in the customer reference, which is acording to
441 Wikipedia at most 34 characters.
443 So this is the new pattern:
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 $'''
465 def __call__(self, transactions, value):
466 return super(StatementASNB, self).__call__(transactions, value)
469class ClosingBalance(BalanceBase):
470 id = 62
473class IntermediateClosingBalance(ClosingBalance):
474 id = '62M'
477class FinalClosingBalance(ClosingBalance):
478 id = '62F'
481class AvailableBalance(BalanceBase):
482 id = 64
485class ForwardAvailableBalance(BalanceBase):
486 id = 65
489class TransactionDetails(Tag):
491 '''Transaction details
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 '''
502class SumEntries(Tag):
503 '''Number and Sum of debit Entries
505 '''
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 '''
514 def __call__(self, transactions, value):
515 data = super(SumEntries, self).__call__(transactions, value)
517 data['status'] = self.status
518 return {
519 self.slug: models.SumAmount(**data)
520 }
523class SumDebitEntries(SumEntries):
524 status = 'D'
525 id = '90D'
528class SumCreditEntries(SumEntries):
529 status = 'C'
530 id = '90C'
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()
557TAG_BY_ID = {t.value.id: t.value for t in Tags}