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.

321 lines
12 KiB

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