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.

257 lines
9.5 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. Don't forget
  89. 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. header_lines = 3
  93. """One file can contain multiple statements, each with its own poorly
  94. documented header. For now, the best thing to do seems to skip that"""
  95. header_regex = '^{1:[0-9A-Z]{25,25}}'
  96. 'The file is considered a valid MT940 file when it contains this line'
  97. footer_regex = '^-XXX$'
  98. 'The line that denotes end of message, we need to create a new statement'
  99. tag_regex = '^:[0-9]{2}[A-Z]*:'
  100. 'The beginning of a record, should be anchored to beginning of the line'
  101. def __init__(self):
  102. self.current_statement = None
  103. self.current_transaction = None
  104. self.statements = []
  105. def create_transaction(self):
  106. """Create and return BankTransaction object."""
  107. transaction = parserlib.BankTransaction()
  108. return transaction
  109. def is_mt940(self, line):
  110. """determine if a line is the header of a statement"""
  111. if not bool(re.match(self.header_regex, line)):
  112. raise ValueError(
  113. 'This does not seem to be a MT940 format bank statement.')
  114. def parse(self, data):
  115. """Parse mt940 bank statement file contents."""
  116. self.is_mt940(data)
  117. iterator = data.replace('\r\n', '\n').split('\n').__iter__()
  118. line = None
  119. record_line = ''
  120. try:
  121. while True:
  122. if not self.current_statement:
  123. self.handle_header(line, iterator)
  124. line = iterator.next()
  125. if not self.is_tag(line) and not self.is_footer(line):
  126. record_line = self.append_continuation_line(
  127. record_line, line)
  128. continue
  129. if record_line:
  130. self.handle_record(record_line)
  131. if self.is_footer(line):
  132. self.handle_footer(line, iterator)
  133. record_line = ''
  134. continue
  135. record_line = line
  136. except StopIteration:
  137. pass
  138. if self.current_statement:
  139. if record_line:
  140. self.handle_record(record_line)
  141. record_line = ''
  142. self.statements.append(self.current_statement)
  143. self.current_statement = None
  144. return self.statements
  145. def append_continuation_line(self, line, continuation_line):
  146. """append a continuation line for a multiline record.
  147. Override and do data cleanups as necessary."""
  148. return line + continuation_line
  149. def create_statement(self):
  150. """create a BankStatement."""
  151. return parserlib.BankStatement()
  152. def is_footer(self, line):
  153. """determine if a line is the footer of a statement"""
  154. return line and bool(re.match(self.footer_regex, line))
  155. def is_tag(self, line):
  156. """determine if a line has a tag"""
  157. return line and bool(re.match(self.tag_regex, line))
  158. def handle_header(self, line, iterator):
  159. """skip header lines, create current statement"""
  160. for dummy_i in range(self.header_lines):
  161. iterator.next()
  162. self.current_statement = self.create_statement()
  163. def handle_footer(self, line, iterator):
  164. """add current statement to list, reset state"""
  165. self.statements.append(self.current_statement)
  166. self.current_statement = None
  167. def handle_record(self, line):
  168. """find a function to handle the record represented by line"""
  169. tag_match = re.match(self.tag_regex, line)
  170. tag = tag_match.group(0).strip(':')
  171. if not hasattr(self, 'handle_tag_%s' % tag):
  172. logging.error('Unknown tag %s', tag)
  173. logging.error(line)
  174. return
  175. handler = getattr(self, 'handle_tag_%s' % tag)
  176. handler(line[tag_match.end():])
  177. def handle_tag_20(self, data):
  178. """Contains unique ? message ID"""
  179. pass
  180. def handle_tag_25(self, data):
  181. """Handle tag 25: local bank account information."""
  182. data = data.replace('EUR', '').replace('.', '').strip()
  183. self.current_statement.local_account = data
  184. def handle_tag_28C(self, data):
  185. """get sequence number _within_this_batch_ - this alone
  186. doesn't provide a unique id!"""
  187. self.current_statement.statement_id = data
  188. def handle_tag_60F(self, data):
  189. """get start balance and currency"""
  190. self.current_statement.local_currency = data[7:10]
  191. self.current_statement.date = datetime.strptime(data[1:7], '%y%m%d')
  192. self.current_statement.start_balance = str2amount(data[0], data[10:])
  193. self.current_statement.statement_id = '%s/%s' % (
  194. self.current_statement.date.strftime('%Y-%m-%d'),
  195. self.current_statement.statement_id,
  196. )
  197. def handle_tag_62F(self, data):
  198. """get ending balance"""
  199. self.current_statement.end_balance = str2amount(data[0], data[10:])
  200. def handle_tag_64(self, data):
  201. """get current balance in currency"""
  202. pass
  203. def handle_tag_65(self, data):
  204. """get future balance in currency"""
  205. pass
  206. def handle_tag_61(self, data):
  207. """get transaction values"""
  208. transaction = self.create_transaction()
  209. self.current_statement.transactions.append(transaction)
  210. self.current_transaction = transaction
  211. transaction.execution_date = datetime.strptime(data[:6], '%y%m%d')
  212. transaction.value_date = datetime.strptime(data[:6], '%y%m%d')
  213. # ...and the rest already is highly bank dependent
  214. def handle_tag_86(self, data):
  215. """details for previous transaction, here most differences between
  216. banks occur"""
  217. pass
  218. # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: