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.

327 lines
13 KiB

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