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.

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