You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

273 lines
10 KiB

  1. #!/usr/bin/env python2
  2. # -*- coding: utf-8 -*-
  3. """Generic parser for MT940 files, base for customized versions per bank."""
  4. ##############################################################################
  5. #
  6. # OpenERP, Open Source Management Solution
  7. # This module copyright (C) 2014 Therp BV (<http://therp.nl>).
  8. #
  9. # This program is free software: you can redistribute it and/or modify
  10. # it under the terms of the GNU Affero General Public License as
  11. # published by the Free Software Foundation, either version 3 of the
  12. # License, or (at your option) any later version.
  13. #
  14. # This program is distributed in the hope that it will be useful,
  15. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. # GNU Affero General Public License for more details.
  18. #
  19. # You should have received a copy of the GNU Affero General Public License
  20. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  21. #
  22. ##############################################################################
  23. import re
  24. import logging
  25. from datetime import datetime
  26. from openerp.addons.bank_statement_parse import parserlib
  27. def str2amount(sign, amount_str):
  28. """Convert sign (C or D) and amount in string to signed amount (float)."""
  29. factor = (1 if sign == 'C' else -1)
  30. return factor * float(amount_str.replace(',', '.'))
  31. def get_subfields(data, codewords):
  32. """Return dictionary with value array for each codeword in data.
  33. For instance:
  34. data =
  35. /BENM//NAME/Kosten/REMI/Periode 01-10-2013 t/m 31-12-2013/ISDT/20
  36. codewords = ['BENM', 'ADDR', 'NAME', 'CNTP', ISDT', 'REMI']
  37. Then return subfields = {
  38. 'BENM': [],
  39. 'NAME': ['Kosten'],
  40. 'REMI': ['Periode 01-10-2013 t', 'm 31-12-2013'],
  41. 'ISDT': ['20'],
  42. }
  43. """
  44. subfields = {}
  45. current_codeword = None
  46. for word in data.split('/'):
  47. if not word and not current_codeword:
  48. continue
  49. if word in codewords:
  50. current_codeword = word
  51. subfields[current_codeword] = []
  52. continue
  53. if current_codeword in subfields:
  54. subfields[current_codeword].append(word)
  55. return subfields
  56. def get_counterpart(transaction, subfield):
  57. """Get counterpart from transaction.
  58. Counterpart is often stored in subfield of tag 86. The subfield
  59. can be BENM, ORDP, CNTP"""
  60. if not subfield:
  61. return # subfield is empty
  62. if len(subfield) >= 1 and subfield[0]:
  63. transaction.remote_account = subfield[0]
  64. if len(subfield) >= 2 and subfield[1]:
  65. transaction.remote_bank_bic = subfield[1]
  66. if len(subfield) >= 3 and subfield[2]:
  67. transaction.remote_owner = subfield[2]
  68. if len(subfield) >= 4 and subfield[3]:
  69. transaction.remote_owner_city = subfield[3]
  70. def handle_common_subfields(transaction, subfields):
  71. """Deal with common functionality for tag 86 subfields."""
  72. # Get counterpart from CNTP, BENM or ORDP subfields:
  73. for counterpart_field in ['CNTP', 'BENM', 'ORDP']:
  74. if counterpart_field in subfields:
  75. get_counterpart(transaction, subfields[counterpart_field])
  76. # REMI: Remitter information (text entered by other party on trans.):
  77. if 'REMI' in subfields:
  78. transaction.message = (
  79. '/'.join(x for x in subfields['REMI'] if x))
  80. # Get transaction reference subfield (might vary):
  81. if transaction.eref in subfields:
  82. transaction.eref = ''.join(
  83. subfields[transaction.eref])
  84. class MT940(object):
  85. """Inherit this class in your account_banking.parsers.models.parser,
  86. define functions to handle the tags you need to handle and adjust static
  87. variables as needed.
  88. At least, you should override handle_tag_61 and handle_tag_86.
  89. Don't forget to call super.
  90. handle_tag_* functions receive the remainder of the the line (that is,
  91. without ':XX:') and are supposed to write into self.current_transaction
  92. """
  93. def __init__(self):
  94. """Initialize parser - override at least header_regex.
  95. This in fact uses the ING syntax, override in others."""
  96. self.mt940_type = 'General'
  97. self.header_lines = 3 # Number of lines to skip
  98. self.header_regex = '^{1:[0-9A-Z]{25,25}}' # Start of relevant data
  99. self.footer_regex = '^-}$|^-XXX$' # Stop processing on seeing this
  100. self.tag_regex = '^:[0-9]{2}[A-Z]*:' # Start of new tag
  101. self.current_statement = None
  102. self.current_transaction = None
  103. self.statements = []
  104. def is_mt940(self, line):
  105. """determine if a line is the header of a statement"""
  106. if not bool(re.match(self.header_regex, line)):
  107. raise ValueError(
  108. 'File starting with %s does not seem to be a'
  109. ' valid %s MT940 format bank statement.' %
  110. (line[:12], self.mt940_type)
  111. )
  112. def parse(self, data):
  113. """Parse mt940 bank statement file contents."""
  114. self.is_mt940(data)
  115. iterator = data.replace('\r\n', '\n').split('\n').__iter__()
  116. line = None
  117. record_line = ''
  118. try:
  119. while True:
  120. if not self.current_statement:
  121. self.handle_header(line, iterator)
  122. line = iterator.next()
  123. if not self.is_tag(line) and not self.is_footer(line):
  124. record_line = self.append_continuation_line(
  125. record_line, line)
  126. continue
  127. if record_line:
  128. self.handle_record(record_line)
  129. if self.is_footer(line):
  130. self.handle_footer(line, iterator)
  131. record_line = ''
  132. continue
  133. record_line = line
  134. except StopIteration:
  135. pass
  136. if self.current_statement:
  137. if record_line:
  138. self.handle_record(record_line)
  139. record_line = ''
  140. self.statements.append(self.current_statement)
  141. self.current_statement = None
  142. return self.statements
  143. def append_continuation_line(self, line, continuation_line):
  144. """append a continuation line for a multiline record.
  145. Override and do data cleanups as necessary."""
  146. return line + continuation_line
  147. def create_statement(self):
  148. """create a BankStatement."""
  149. return parserlib.BankStatement()
  150. def create_transaction(self):
  151. """Create and return BankTransaction object."""
  152. return parserlib.BankTransaction()
  153. def is_footer(self, line):
  154. """determine if a line is the footer of a statement"""
  155. return line and bool(re.match(self.footer_regex, line))
  156. def is_tag(self, line):
  157. """determine if a line has a tag"""
  158. return line and bool(re.match(self.tag_regex, line))
  159. def handle_header(self, line, iterator):
  160. """skip header lines, create current statement"""
  161. for dummy_i in range(self.header_lines):
  162. iterator.next()
  163. self.current_statement = self.create_statement()
  164. def handle_footer(self, line, iterator):
  165. """add current statement to list, reset state"""
  166. self.statements.append(self.current_statement)
  167. self.current_statement = None
  168. def handle_record(self, line):
  169. """find a function to handle the record represented by line"""
  170. tag_match = re.match(self.tag_regex, line)
  171. tag = tag_match.group(0).strip(':')
  172. if not hasattr(self, 'handle_tag_%s' % tag):
  173. logging.error('Unknown tag %s', tag)
  174. logging.error(line)
  175. return
  176. handler = getattr(self, 'handle_tag_%s' % tag)
  177. handler(line[tag_match.end():])
  178. def handle_tag_20(self, data):
  179. """Contains unique ? message ID"""
  180. pass
  181. def handle_tag_25(self, data):
  182. """Handle tag 25: local bank account information."""
  183. data = data.replace('EUR', '').replace('.', '').strip()
  184. self.current_statement.local_account = data
  185. def handle_tag_28C(self, data):
  186. """Sequence number within batch - normally only zeroes."""
  187. pass
  188. def handle_tag_60F(self, data):
  189. """get start balance and currency"""
  190. # For the moment only first 60F record
  191. # The alternative would be to split the file and start a new
  192. # statement for each 20: tag encountered.
  193. stmt = self.current_statement
  194. if not stmt.local_currency:
  195. stmt.local_currency = data[7:10]
  196. stmt.start_balance = str2amount(data[0], data[10:])
  197. def handle_tag_61(self, data):
  198. """get transaction values"""
  199. transaction = self.create_transaction()
  200. self.current_statement.transactions.append(transaction)
  201. self.current_transaction = transaction
  202. transaction.execution_date = datetime.strptime(data[:6], '%y%m%d')
  203. transaction.value_date = datetime.strptime(data[:6], '%y%m%d')
  204. # ...and the rest already is highly bank dependent
  205. def handle_tag_62F(self, data):
  206. """Get ending balance, statement date and id.
  207. We use the date on the last 62F tag as statement date, as the date
  208. on the 60F record (previous end balance) might contain a date in
  209. a previous period.
  210. We generate the statement.id from the local_account and the end-date,
  211. this should normally be unique, provided there is a maximum of
  212. one statement per day.
  213. Depending on the bank, there might be multiple 62F tags in the import
  214. file. The last one counts.
  215. """
  216. stmt = self.current_statement
  217. stmt.end_balance = str2amount(data[0], data[10:])
  218. stmt.date = datetime.strptime(data[1:7], '%y%m%d')
  219. stmt.id = '%s-%s' % (
  220. stmt.local_account,
  221. stmt.date.strftime('%Y-%m-%d'),
  222. )
  223. def handle_tag_64(self, data):
  224. """get current balance in currency"""
  225. pass
  226. def handle_tag_65(self, data):
  227. """get future balance in currency"""
  228. pass
  229. def handle_tag_86(self, data):
  230. """details for previous transaction, here most differences between
  231. banks occur"""
  232. pass
  233. # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: