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.

256 lines
9.9 KiB

  1. """Class to parse camt files."""
  2. # Copyright 2013-2016 Therp BV <https://therp.nl>
  3. # Copyright 2017 Open Net Sàrl
  4. # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
  5. import re
  6. from lxml import etree
  7. from odoo import models
  8. class CamtParser(models.AbstractModel):
  9. _name = 'account.bank.statement.import.camt.parser'
  10. _description = 'Account Bank Statement Import CAMT parser'
  11. def parse_amount(self, ns, node):
  12. """Parse element that contains Amount and CreditDebitIndicator."""
  13. if node is None:
  14. return 0.0
  15. sign = 1
  16. amount = 0.0
  17. sign_node = node.xpath('ns:CdtDbtInd', namespaces={'ns': ns})
  18. if not sign_node:
  19. sign_node = node.xpath(
  20. '../../ns:CdtDbtInd', namespaces={'ns': ns})
  21. if sign_node and sign_node[0].text == 'DBIT':
  22. sign = -1
  23. amount_node = node.xpath('ns:Amt', namespaces={'ns': ns})
  24. if not amount_node:
  25. amount_node = node.xpath(
  26. './ns:AmtDtls/ns:TxAmt/ns:Amt', namespaces={'ns': ns})
  27. if amount_node:
  28. amount = sign * float(amount_node[0].text)
  29. return amount
  30. def add_value_from_node(
  31. self, ns, node, xpath_str, obj, attr_name, join_str=None):
  32. """Add value to object from first or all nodes found with xpath.
  33. If xpath_str is a list (or iterable), it will be seen as a series
  34. of search path's in order of preference. The first item that results
  35. in a found node will be used to set a value."""
  36. if not isinstance(xpath_str, (list, tuple)):
  37. xpath_str = [xpath_str]
  38. for search_str in xpath_str:
  39. found_node = node.xpath(search_str, namespaces={'ns': ns})
  40. if found_node:
  41. if join_str is None:
  42. attr_value = found_node[0].text
  43. else:
  44. attr_value = join_str.join([x.text for x in found_node])
  45. obj[attr_name] = attr_value
  46. break
  47. def parse_transaction_details(self, ns, node, transaction):
  48. """Parse TxDtls node."""
  49. # message
  50. self.add_value_from_node(
  51. ns, node, [
  52. './ns:RmtInf/ns:Ustrd|./ns:RtrInf/ns:AddtlInf',
  53. './ns:AddtlNtryInf',
  54. './ns:Refs/ns:InstrId',
  55. ], transaction, 'name', join_str='\n')
  56. # name
  57. self.add_value_from_node(
  58. ns, node, [
  59. './ns:AddtlTxInf',
  60. ], transaction, 'note', join_str='\n')
  61. # eref
  62. self.add_value_from_node(
  63. ns, node, [
  64. './ns:RmtInf/ns:Strd/ns:CdtrRefInf/ns:Ref',
  65. './ns:Refs/ns:EndToEndId',
  66. './ns:Ntry/ns:AcctSvcrRef'
  67. ],
  68. transaction, 'ref'
  69. )
  70. amount = self.parse_amount(ns, node)
  71. if amount != 0.0:
  72. transaction['amount'] = amount
  73. # remote party values
  74. party_type = 'Dbtr'
  75. party_type_node = node.xpath(
  76. '../../ns:CdtDbtInd', namespaces={'ns': ns})
  77. if party_type_node and party_type_node[0].text != 'CRDT':
  78. party_type = 'Cdtr'
  79. party_node = node.xpath(
  80. './ns:RltdPties/ns:%s' % party_type, namespaces={'ns': ns})
  81. if party_node:
  82. name_node = node.xpath(
  83. './ns:RltdPties/ns:%s/ns:Nm' % party_type,
  84. namespaces={'ns': ns})
  85. if name_node:
  86. self.add_value_from_node(
  87. ns, party_node[0], './ns:Nm', transaction, 'partner_name')
  88. else:
  89. self.add_value_from_node(
  90. ns, party_node[0], './ns:PstlAdr/ns:AdrLine',
  91. transaction, 'partner_name')
  92. # Get remote_account from iban or from domestic account:
  93. account_node = node.xpath(
  94. './ns:RltdPties/ns:%sAcct/ns:Id' % party_type,
  95. namespaces={'ns': ns}
  96. )
  97. if account_node:
  98. iban_node = account_node[0].xpath(
  99. './ns:IBAN', namespaces={'ns': ns})
  100. if iban_node:
  101. transaction['account_number'] = iban_node[0].text
  102. else:
  103. self.add_value_from_node(
  104. ns, account_node[0], './ns:Othr/ns:Id', transaction,
  105. 'account_number'
  106. )
  107. def parse_entry(self, ns, node):
  108. """Parse an Ntry node and yield transactions"""
  109. transaction = {'name': '/', 'amount': 0} # fallback defaults
  110. self.add_value_from_node(
  111. ns, node, './ns:BookgDt/ns:Dt', transaction, 'date')
  112. amount = self.parse_amount(ns, node)
  113. if amount != 0.0:
  114. transaction['amount'] = amount
  115. self.add_value_from_node(
  116. ns, node, './ns:AddtlNtryInf', transaction, 'name')
  117. self.add_value_from_node(
  118. ns, node, [
  119. './ns:NtryDtls/ns:RmtInf/ns:Strd/ns:CdtrRefInf/ns:Ref',
  120. './ns:NtryDtls/ns:Btch/ns:PmtInfId',
  121. './ns:NtryDtls/ns:TxDtls/ns:Refs/ns:AcctSvcrRef'
  122. ],
  123. transaction, 'ref'
  124. )
  125. details_nodes = node.xpath(
  126. './ns:NtryDtls/ns:TxDtls', namespaces={'ns': ns})
  127. if len(details_nodes) == 0:
  128. yield transaction
  129. return
  130. transaction_base = transaction
  131. for node in details_nodes:
  132. transaction = transaction_base.copy()
  133. self.parse_transaction_details(ns, node, transaction)
  134. yield transaction
  135. def get_balance_amounts(self, ns, node):
  136. """Return opening and closing balance.
  137. Depending on kind of balance and statement, the balance might be in a
  138. different kind of node:
  139. OPBD = OpeningBalance
  140. PRCD = PreviousClosingBalance
  141. ITBD = InterimBalance (first ITBD is start-, second is end-balance)
  142. CLBD = ClosingBalance
  143. """
  144. start_balance_node = None
  145. end_balance_node = None
  146. for node_name in ['OPBD', 'PRCD', 'CLBD', 'ITBD']:
  147. code_expr = (
  148. './ns:Bal/ns:Tp/ns:CdOrPrtry/ns:Cd[text()="%s"]/../../..' %
  149. node_name
  150. )
  151. balance_node = node.xpath(code_expr, namespaces={'ns': ns})
  152. if balance_node:
  153. if node_name in ['OPBD', 'PRCD']:
  154. start_balance_node = balance_node[0]
  155. elif node_name == 'CLBD':
  156. end_balance_node = balance_node[0]
  157. else:
  158. if not start_balance_node:
  159. start_balance_node = balance_node[0]
  160. if not end_balance_node:
  161. end_balance_node = balance_node[-1]
  162. return (
  163. self.parse_amount(ns, start_balance_node),
  164. self.parse_amount(ns, end_balance_node)
  165. )
  166. def parse_statement(self, ns, node):
  167. """Parse a single Stmt node."""
  168. result = {}
  169. self.add_value_from_node(
  170. ns, node, [
  171. './ns:Acct/ns:Id/ns:IBAN',
  172. './ns:Acct/ns:Id/ns:Othr/ns:Id',
  173. ], result, 'account_number'
  174. )
  175. self.add_value_from_node(
  176. ns, node, './ns:Id', result, 'name')
  177. self.add_value_from_node(
  178. ns, node, './ns:Acct/ns:Ccy', result, 'currency')
  179. result['balance_start'], result['balance_end_real'] = (
  180. self.get_balance_amounts(ns, node))
  181. entry_nodes = node.xpath('./ns:Ntry', namespaces={'ns': ns})
  182. transactions = []
  183. for entry_node in entry_nodes:
  184. transactions.extend(self.parse_entry(ns, entry_node))
  185. result['transactions'] = transactions
  186. result['date'] = None
  187. if transactions:
  188. result['date'] = sorted(transactions,
  189. key=lambda x: x['date'],
  190. reverse=True
  191. )[0]['date']
  192. return result
  193. def check_version(self, ns, root):
  194. """Validate validity of camt file."""
  195. # Check whether it is camt at all:
  196. re_camt = re.compile(
  197. r'(^urn:iso:std:iso:20022:tech:xsd:camt.'
  198. r'|^ISO:camt.)'
  199. )
  200. if not re_camt.search(ns):
  201. raise ValueError('no camt: ' + ns)
  202. # Check whether version 052 ,053 or 054:
  203. re_camt_version = re.compile(
  204. r'(^urn:iso:std:iso:20022:tech:xsd:camt.054.'
  205. r'|^urn:iso:std:iso:20022:tech:xsd:camt.053.'
  206. r'|^urn:iso:std:iso:20022:tech:xsd:camt.052.'
  207. r'|^ISO:camt.054.'
  208. r'|^ISO:camt.053.'
  209. r'|^ISO:camt.052.)'
  210. )
  211. if not re_camt_version.search(ns):
  212. raise ValueError('no camt 052 or 053 or 054: ' + ns)
  213. # Check GrpHdr element:
  214. root_0_0 = root[0][0].tag[len(ns) + 2:] # strip namespace
  215. if root_0_0 != 'GrpHdr':
  216. raise ValueError('expected GrpHdr, got: ' + root_0_0)
  217. def parse(self, data):
  218. """Parse a camt.052 or camt.053 or camt.054 file."""
  219. try:
  220. root = etree.fromstring(
  221. data, parser=etree.XMLParser(recover=True))
  222. except etree.XMLSyntaxError:
  223. # ABNAmro is known to mix up encodings
  224. root = etree.fromstring(
  225. data.decode('iso-8859-15').encode('utf-8'))
  226. if root is None:
  227. raise ValueError(
  228. 'Not a valid xml file, or not an xml file at all.')
  229. ns = root.tag[1:root.tag.index("}")]
  230. self.check_version(ns, root)
  231. statements = []
  232. currency = None
  233. account_number = None
  234. for node in root[0][1:]:
  235. statement = self.parse_statement(ns, node)
  236. if len(statement['transactions']):
  237. if 'currency' in statement:
  238. currency = statement.pop('currency')
  239. if 'account_number' in statement:
  240. account_number = statement.pop('account_number')
  241. statements.append(statement)
  242. return currency, account_number, statements