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.

253 lines
9.6 KiB

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