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.

255 lines
10 KiB

9 years ago
  1. # -*- coding: utf-8 -*-
  2. """Class to parse camt files."""
  3. ##############################################################################
  4. #
  5. # Copyright (C) 2013-2015 Therp BV <http://therp.nl>
  6. #
  7. # This program is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU Affero General Public License as published
  9. # by the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This program is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU Affero General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU Affero General Public License
  18. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  19. #
  20. ##############################################################################
  21. import re
  22. from datetime import datetime
  23. from lxml import etree
  24. from openerp.addons.account_bank_statement_import.parserlib import (
  25. BankStatement)
  26. class CamtParser(object):
  27. """Parser for camt bank statement import files."""
  28. def parse_amount(self, ns, node):
  29. """Parse element that contains Amount and CreditDebitIndicator."""
  30. if node is None:
  31. return 0.0
  32. sign = 1
  33. amount = 0.0
  34. sign_node = node.xpath('ns:CdtDbtInd', namespaces={'ns': ns})
  35. if sign_node and sign_node[0].text == 'DBIT':
  36. sign = -1
  37. amount_node = node.xpath('ns:Amt', namespaces={'ns': ns})
  38. if amount_node:
  39. amount = sign * float(amount_node[0].text)
  40. return amount
  41. def add_value_from_node(
  42. self, ns, node, xpath_str, obj, attr_name, join_str=None):
  43. """Add value to object from first or all nodes found with xpath.
  44. If xpath_str is a list (or iterable), it will be seen as a series
  45. of search path's in order of preference. The first item that results
  46. in a found node will be used to set a value."""
  47. if not isinstance(xpath_str, (list, tuple)):
  48. xpath_str = [xpath_str]
  49. for search_str in xpath_str:
  50. found_node = node.xpath(search_str, namespaces={'ns': ns})
  51. if found_node:
  52. if join_str is None:
  53. attr_value = found_node[0].text
  54. else:
  55. attr_value = join_str.join([x.text for x in found_node])
  56. setattr(obj, attr_name, attr_value)
  57. break
  58. def parse_transaction_details(self, ns, node, transaction):
  59. """Parse transaction details (message, party, account...)."""
  60. # message
  61. self.add_value_from_node(
  62. ns, node, [
  63. './ns:RmtInf/ns:Ustrd',
  64. './ns:AddtlTxInf',
  65. './ns:AddtlNtryInf',
  66. ], transaction, 'message', join_str='\n')
  67. # eref
  68. self.add_value_from_node(
  69. ns, node, [
  70. './ns:RmtInf/ns:Strd/ns:CdtrRefInf/ns:Ref',
  71. './ns:Refs/ns:EndToEndId',
  72. ],
  73. transaction, 'eref'
  74. )
  75. # remote party values
  76. party_type = 'Dbtr'
  77. party_type_node = node.xpath(
  78. '../../ns:CdtDbtInd', namespaces={'ns': ns})
  79. if party_type_node and party_type_node[0].text != 'CRDT':
  80. party_type = 'Cdtr'
  81. party_node = node.xpath(
  82. './ns:RltdPties/ns:%s' % party_type, namespaces={'ns': ns})
  83. if party_node:
  84. self.add_value_from_node(
  85. ns, party_node[0], './ns:Nm', transaction, 'remote_owner')
  86. self.add_value_from_node(
  87. ns, party_node[0], './ns:PstlAdr/ns:Ctry', transaction,
  88. 'remote_owner_country'
  89. )
  90. address_node = party_node[0].xpath(
  91. './ns:PstlAdr/ns:AdrLine', namespaces={'ns': ns})
  92. if address_node:
  93. transaction.remote_owner_address = [address_node[0].text]
  94. # Get remote_account from iban or from domestic account:
  95. account_node = node.xpath(
  96. './ns:RltdPties/ns:%sAcct/ns:Id' % party_type,
  97. namespaces={'ns': ns}
  98. )
  99. if account_node:
  100. iban_node = account_node[0].xpath(
  101. './ns:IBAN', namespaces={'ns': ns})
  102. if iban_node:
  103. transaction.remote_account = iban_node[0].text
  104. bic_node = node.xpath(
  105. './ns:RltdAgts/ns:%sAgt/ns:FinInstnId/ns:BIC' % party_type,
  106. namespaces={'ns': ns}
  107. )
  108. if bic_node:
  109. transaction.remote_bank_bic = bic_node[0].text
  110. else:
  111. self.add_value_from_node(
  112. ns, account_node[0], './ns:Othr/ns:Id', transaction,
  113. 'remote_account'
  114. )
  115. def parse_transaction(self, ns, node, transaction):
  116. """Parse transaction (entry) node."""
  117. self.add_value_from_node(
  118. ns, node, './ns:BkTxCd/ns:Prtry/ns:Cd', transaction,
  119. 'transfer_type'
  120. )
  121. self.add_value_from_node(
  122. ns, node, './ns:BookgDt/ns:Dt', transaction, 'execution_date')
  123. self.add_value_from_node(
  124. ns, node, './ns:ValDt/ns:Dt', transaction, 'value_date')
  125. transaction.transferred_amount = self.parse_amount(ns, node)
  126. details_node = node.xpath(
  127. './ns:NtryDtls/ns:TxDtls', namespaces={'ns': ns})
  128. if details_node:
  129. self.parse_transaction_details(ns, details_node[0], transaction)
  130. if not transaction.message:
  131. self.add_value_from_node(
  132. ns, node, './ns:AddtlNtryInf', transaction, 'message')
  133. if not transaction.eref:
  134. self.add_value_from_node(
  135. ns, node, [
  136. './ns:NtryDtls/ns:Btch/ns:PmtInfId',
  137. ],
  138. transaction, 'eref'
  139. )
  140. transaction.data = etree.tostring(node)
  141. return transaction
  142. def get_balance_amounts(self, ns, node):
  143. """Return opening and closing balance.
  144. Depending on kind of balance and statement, the balance might be in a
  145. different kind of node:
  146. OPBD = OpeningBalance
  147. PRCD = PreviousClosingBalance
  148. ITBD = InterimBalance (first ITBD is start-, second is end-balance)
  149. CLBD = ClosingBalance
  150. """
  151. start_balance_node = None
  152. end_balance_node = None
  153. for node_name in ['OPBD', 'PRCD', 'CLBD', 'ITBD']:
  154. code_expr = (
  155. './ns:Bal/ns:Tp/ns:CdOrPrtry/ns:Cd[text()="%s"]/../../..' %
  156. node_name
  157. )
  158. balance_node = node.xpath(code_expr, namespaces={'ns': ns})
  159. if balance_node:
  160. if node_name in ['OPBD', 'PRCD']:
  161. start_balance_node = balance_node[0]
  162. elif node_name == 'CLBD':
  163. end_balance_node = balance_node[0]
  164. else:
  165. if not start_balance_node:
  166. start_balance_node = balance_node[0]
  167. if not end_balance_node:
  168. end_balance_node = balance_node[-1]
  169. return (
  170. self.parse_amount(ns, start_balance_node),
  171. self.parse_amount(ns, end_balance_node)
  172. )
  173. def parse_statement(self, ns, node):
  174. """Parse a single Stmt node."""
  175. statement = BankStatement()
  176. self.add_value_from_node(
  177. ns, node, [
  178. './ns:Acct/ns:Id/ns:IBAN',
  179. './ns:Acct/ns:Id/ns:Othr/ns:Id',
  180. ], statement, 'local_account'
  181. )
  182. self.add_value_from_node(
  183. ns, node, './ns:Id', statement, 'statement_id')
  184. self.add_value_from_node(
  185. ns, node, './ns:Acct/ns:Ccy', statement, 'local_currency')
  186. (statement.start_balance, statement.end_balance) = (
  187. self.get_balance_amounts(ns, node))
  188. transaction_nodes = node.xpath('./ns:Ntry', namespaces={'ns': ns})
  189. for entry_node in transaction_nodes:
  190. transaction = statement.create_transaction()
  191. self.parse_transaction(ns, entry_node, transaction)
  192. if statement['transactions']:
  193. execution_date = statement['transactions'][0].execution_date
  194. statement.date = datetime.strptime(execution_date, "%Y-%m-%d")
  195. # Prepend date of first transaction to improve id uniquenes
  196. if execution_date not in statement.statement_id:
  197. statement.statement_id = "%s-%s" % (
  198. execution_date, statement.statement_id)
  199. return statement
  200. def check_version(self, ns, root):
  201. """Validate validity of camt file."""
  202. # Check wether it is camt at all:
  203. re_camt = re.compile(
  204. r'(^urn:iso:std:iso:20022:tech:xsd:camt.'
  205. r'|^ISO:camt.)'
  206. )
  207. if not re_camt.search(ns):
  208. raise ValueError('no camt: ' + ns)
  209. # Check wether version 052 ,053 or 054:
  210. re_camt_version = re.compile(
  211. r'(^urn:iso:std:iso:20022:tech:xsd:camt.054.'
  212. r'|^urn:iso:std:iso:20022:tech:xsd:camt.053.'
  213. r'|^urn:iso:std:iso:20022:tech:xsd:camt.052.'
  214. r'|^ISO:camt.054.'
  215. r'|^ISO:camt.053.'
  216. r'|^ISO:camt.052.)'
  217. )
  218. if not re_camt_version.search(ns):
  219. raise ValueError('no camt 052 or 053: ' + ns)
  220. # Check GrpHdr element:
  221. root_0_0 = root[0][0].tag[len(ns) + 2:] # strip namespace
  222. if root_0_0 != 'GrpHdr':
  223. raise ValueError('expected GrpHdr, got: ' + root_0_0)
  224. def parse(self, data):
  225. """Parse a camt.052 or camt.053 file."""
  226. try:
  227. root = etree.fromstring(
  228. data, parser=etree.XMLParser(recover=True))
  229. except etree.XMLSyntaxError:
  230. # ABNAmro is known to mix up encodings
  231. root = etree.fromstring(
  232. data.decode('iso-8859-15').encode('utf-8'))
  233. if root is None:
  234. raise ValueError(
  235. 'Not a valid xml file, or not an xml file at all.')
  236. ns = root.tag[1:root.tag.index("}")]
  237. self.check_version(ns, root)
  238. statements = []
  239. for node in root[0][1:]:
  240. statement = self.parse_statement(ns, node)
  241. if len(statement['transactions']):
  242. statements.append(statement)
  243. return statements