Merge lp:~therp-nl/banking-addons/6.1-add_camt_import into lp:banking-addons/6.1

Proposed by Stefan Rijnhart (Opener)
Status: Merged
Merged at revision: 187
Proposed branch: lp:~therp-nl/banking-addons/6.1-add_camt_import
Merge into: lp:banking-addons/6.1
Diff against target: 382 lines (+341/-1)
4 files modified
account_banking/parsers/models.py (+29/-1)
account_banking_camt/__init__.py (+1/-0)
account_banking_camt/__openerp__.py (+33/-0)
account_banking_camt/camt.py (+278/-0)
To merge this branch: bzr merge lp:~therp-nl/banking-addons/6.1-add_camt_import
Reviewer Review Type Date Requested Status
Holger Brunn (Therp) code review Approve
Review via email: mp+190641@code.launchpad.net

Commit message

[ADD] Support for SEPA CAMT.053 bank statements

To post a comment you must log in.
Revision history for this message
Holger Brunn (Therp) (hbrunn) wrote :
review: Approve (code review)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'account_banking/parsers/models.py'
2--- account_banking/parsers/models.py 2012-01-17 08:48:10 +0000
3+++ account_banking/parsers/models.py 2013-10-11 12:39:01 +0000
4@@ -19,6 +19,7 @@
5 #
6 ##############################################################################
7
8+import re
9 from tools.translate import _
10
11 class mem_bank_statement(object):
12@@ -34,7 +35,7 @@
13 # Lock attributes to enable parsers to trigger non-conformity faults
14 __slots__ = [
15 'start_balance','end_balance', 'date', 'local_account',
16- 'local_currency', 'id', 'statements'
17+ 'local_currency', 'id', 'transactions'
18 ]
19 def __init__(self, *args, **kwargs):
20 super(mem_bank_statement, self).__init__(*args, **kwargs)
21@@ -353,6 +354,33 @@
22 name = "%s-%d" % (base, suffix)
23 return name
24
25+ def get_unique_account_identifier(self, cr, account):
26+ """
27+ Get an identifier for a local bank account, based on the last
28+ characters of the account number with minimum length 3.
29+ The identifier should be unique amongst the company accounts
30+
31+ Presumably, the bank account is one of the company accounts
32+ itself but importing bank statements for non-company accounts
33+ is not prevented anywhere else in the system so the 'account'
34+ param being a company account is not enforced here either.
35+ """
36+ def normalize(account_no):
37+ return re.sub('\s', '', account_no)
38+
39+ account = normalize(account)
40+ cr.execute(
41+ """SELECT acc_number FROM res_partner_bank
42+ WHERE company_id IS NOT NULL""")
43+ accounts = [normalize(row[0]) for row in cr.fetchall()]
44+ tail_length = 3
45+ while tail_length <= len(account):
46+ tail = account[-tail_length:]
47+ if len([acc for acc in accounts if acc.endswith(tail)]) < 2:
48+ return tail
49+ tail_length += 1
50+ return account
51+
52 def parse(self, cr, data):
53 '''
54 Parse data.
55
56=== added directory 'account_banking_camt'
57=== added file 'account_banking_camt/__init__.py'
58--- account_banking_camt/__init__.py 1970-01-01 00:00:00 +0000
59+++ account_banking_camt/__init__.py 2013-10-11 12:39:01 +0000
60@@ -0,0 +1,1 @@
61+import camt
62
63=== added file 'account_banking_camt/__openerp__.py'
64--- account_banking_camt/__openerp__.py 1970-01-01 00:00:00 +0000
65+++ account_banking_camt/__openerp__.py 2013-10-11 12:39:01 +0000
66@@ -0,0 +1,33 @@
67+##############################################################################
68+#
69+# Copyright (C) 2013 Therp BV (<http://therp.nl>)
70+# All Rights Reserved
71+#
72+# This program is free software: you can redistribute it and/or modify
73+# it under the terms of the GNU Affero General Public License as published by
74+# the Free Software Foundation, either version 3 of the License, or
75+# (at your option) any later version.
76+#
77+# This program is distributed in the hope that it will be useful,
78+# but WITHOUT ANY WARRANTY; without even the implied warranty of
79+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
80+# GNU Affero General Public License for more details.
81+#
82+# You should have received a copy of the GNU Affero General Public License
83+# along with this program. If not, see <http://www.gnu.org/licenses/>.
84+#
85+##############################################################################
86+{
87+ 'name': 'CAMT Format Bank Statements Import',
88+ 'version': '0.1',
89+ 'license': 'AGPL-3',
90+ 'author': 'Therp BV',
91+ 'website': 'https://launchpad.net/banking-addons',
92+ 'category': 'Banking addons',
93+ 'depends': ['account_banking'],
94+ 'description': '''
95+Module to import SEPA CAMT.053 Format bank statement files. Based
96+on the Banking addons framework.
97+ ''',
98+ 'installable': True,
99+}
100
101=== added file 'account_banking_camt/camt.py'
102--- account_banking_camt/camt.py 1970-01-01 00:00:00 +0000
103+++ account_banking_camt/camt.py 2013-10-11 12:39:01 +0000
104@@ -0,0 +1,278 @@
105+# -*- coding: utf-8 -*-
106+##############################################################################
107+#
108+# Copyright (C) 2013 Therp BV (<http://therp.nl>)
109+# All Rights Reserved
110+#
111+# This program is free software: you can redistribute it and/or modify
112+# it under the terms of the GNU Affero General Public License as published by
113+# the Free Software Foundation, either version 3 of the License, or
114+# (at your option) any later version.
115+#
116+# This program is distributed in the hope that it will be useful,
117+# but WITHOUT ANY WARRANTY; without even the implied warranty of
118+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
119+# GNU Affero General Public License for more details.
120+#
121+# You should have received a copy of the GNU Affero General Public License
122+# along with this program. If not, see <http://www.gnu.org/licenses/>.
123+#
124+##############################################################################
125+
126+from lxml import etree
127+from openerp.osv.orm import except_orm
128+from account_banking.parsers import models
129+from account_banking.parsers.convert import str2date
130+
131+bt = models.mem_bank_transaction
132+
133+class transaction(models.mem_bank_transaction):
134+
135+ def __init__(self, values, *args, **kwargs):
136+ super(transaction, self).__init__(*args, **kwargs)
137+ for attr in values:
138+ setattr(self, attr, values[attr])
139+
140+ def is_valid(self):
141+ return not self.error_message
142+
143+class parser(models.parser):
144+ code = 'CAMT'
145+ country_code = 'NL'
146+ name = 'Generic CAMT Format'
147+ doc = '''\
148+CAMT Format parser
149+'''
150+
151+ def tag(self, node):
152+ """
153+ Return the tag of a node, stripped from its namespace
154+ """
155+ return node.tag[len(self.ns):]
156+
157+ def assert_tag(self, node, expected):
158+ """
159+ Get node's stripped tag and compare with expected
160+ """
161+ assert self.tag(node) == expected, (
162+ "Expected tag '%s', got '%s' instead" %
163+ (self.tag(node), expected))
164+
165+ def xpath(self, node, expr):
166+ """
167+ Wrap namespaces argument into call to Element.xpath():
168+
169+ self.xpath(node, './ns:Acct/ns:Id')
170+ """
171+ return node.xpath(expr, namespaces={'ns': self.ns[1:-1]})
172+
173+ def find(self, node, expr):
174+ """
175+ Like xpath(), but return first result if any or else False
176+
177+ Return None to test nodes for being truesy
178+ """
179+ result = node.xpath(expr, namespaces={'ns': self.ns[1:-1]})
180+ if result:
181+ return result[0]
182+ return None
183+
184+ def get_balance_type_node(self, node, balance_type):
185+ """
186+ :param node: BkToCstmrStmt/Stmt/Bal node
187+ :param balance type: one of 'OPBD', 'PRCD', 'ITBD', 'CLBD'
188+ """
189+ code_expr = './ns:Bal/ns:Tp/ns:CdOrPrtry/ns:Cd[text()="%s"]/../../..' % balance_type
190+ return self.xpath(node, code_expr)
191+
192+ def parse_amount(self, node):
193+ """
194+ Parse an element that contains both Amount and CreditDebitIndicator
195+
196+ :return: signed amount
197+ :returntype: float
198+ """
199+ sign = -1 if node.find(self.ns + 'CdtDbtInd').text == 'DBIT' else 1
200+ return sign * float(node.find(self.ns + 'Amt').text)
201+
202+ def get_start_balance(self, node):
203+ """
204+ Find the (only) balance node with code OpeningBalance, or
205+ the only one with code 'PreviousClosingBalance'
206+ or the first balance node with code InterimBalance in
207+ the case of preceeding pagination.
208+
209+ :param node: BkToCstmrStmt/Stmt/Bal node
210+ """
211+ nodes = (
212+ self.get_balance_type_node(node, 'OPBD') or
213+ self.get_balance_type_node(node, 'PRCD') or
214+ self.get_balance_type_node(node, 'ITBD'))
215+ return self.parse_amount(nodes[0])
216+
217+ def get_end_balance(self, node):
218+ """
219+ Find the (only) balance node with code ClosingBalance, or
220+ the second (and last) balance node with code InterimBalance in
221+ the case of continued pagination.
222+
223+ :param node: BkToCstmrStmt/Stmt/Bal node
224+ """
225+ nodes = (
226+ self.get_balance_type_node(node, 'CLBD') or
227+ self.get_balance_type_node(node, 'ITBD'))
228+ return self.parse_amount(nodes[-1])
229+
230+ def parse_Stmt(self, cr, node):
231+ """
232+ Parse a single Stmt node.
233+
234+ Be sure to craft a unique, but short enough statement identifier,
235+ as it is used as the basis of the generated move lines' names
236+ which overflow when using the full IBAN and CAMT statement id.
237+ """
238+ statement = models.mem_bank_statement()
239+ statement.local_account = (
240+ self.xpath(node, './ns:Acct/ns:Id/ns:IBAN')[0].text
241+ if self.xpath(node, './ns:Acct/ns:Id/ns:IBAN')
242+ else self.xpath(node, './ns:Acct/ns:Id/ns:Othr/ns:Id')[0].text)
243+
244+ identifier = node.find(self.ns + 'Id').text
245+ if identifier.upper().startswith('CAMT053'):
246+ identifier = identifier[7:]
247+ statement.id = self.get_unique_statement_id(
248+ cr, "%s-%s" % (
249+ self.get_unique_account_identifier(
250+ cr, statement.local_account),
251+ identifier)
252+ )
253+
254+ statement.local_currency = self.xpath(node, './ns:Acct/ns:Ccy')[0].text
255+ statement.start_balance = self.get_start_balance(node)
256+ statement.end_balance = self.get_end_balance(node)
257+ number = 0
258+ for Ntry in self.xpath(node, './ns:Ntry'):
259+ transaction_detail = self.parse_Ntry(Ntry)
260+ if number == 0:
261+ # Take the statement date from the first transaction
262+ statement.date = str2date(
263+ transaction_detail['execution_date'], "%Y-%m-%d")
264+ number += 1
265+ transaction_detail['id'] = str(number).zfill(4)
266+ statement.transactions.append(
267+ transaction(transaction_detail))
268+ return statement
269+
270+ def get_transfer_type(self, node):
271+ """
272+ Map entry descriptions to transfer types. To extend with
273+ proper mapping from BkTxCd/Domn/Cd/Fmly/Cd to transfer types
274+ if we can get our hands on real life samples.
275+
276+ For now, leave as a hook for bank specific overrides to map
277+ properietary codes from BkTxCd/Prtry/Cd.
278+
279+ :param node: Ntry node
280+ """
281+ return bt.ORDER
282+
283+ def parse_Ntry(self, node):
284+ """
285+ :param node: Ntry node
286+ """
287+ entry_details = {
288+ 'execution_date': self.xpath(node, './ns:BookgDt/ns:Dt')[0].text,
289+ 'effective_date': self.xpath(node, './ns:ValDt/ns:Dt')[0].text,
290+ 'transfer_type': self.get_transfer_type(node),
291+ 'transferred_amount': self.parse_amount(node)
292+ }
293+ TxDtls = self.xpath(node, './ns:NtryDtls/ns:TxDtls')
294+ if len(TxDtls) == 1:
295+ vals = self.parse_TxDtls(TxDtls[0], entry_details)
296+ else:
297+ vals = entry_details
298+ return vals
299+
300+ def get_party_values(self, TxDtls):
301+ """
302+ Determine to get either the debtor or creditor party node
303+ and extract the available data from it
304+ """
305+ vals = {}
306+ party_type = self.find(
307+ TxDtls, '../../ns:CdtDbtInd').text == 'CRDT' and 'Dbtr' or 'Cdtr'
308+ party_node = self.find(TxDtls, './ns:RltdPties/ns:%s' % party_type)
309+ account_node = self.find(
310+ TxDtls, './ns:RltdPties/ns:%sAcct/ns:Id' % party_type)
311+ bic_node = self.find(
312+ TxDtls,
313+ './ns:RltdAgts/ns:%sAgt/ns:FinInstnId/ns:BIC' % party_type)
314+ if party_node is not None:
315+ name_node = self.find(party_node, './ns:Nm')
316+ vals['remote_owner'] = (
317+ name_node.text if name_node is not None else False)
318+ country_node = self.find(party_node, './ns:PstlAdr/ns:Ctry')
319+ vals['remote_owner_country'] = (
320+ country_node.text if country_node is not None else False)
321+ address_node = self.find(party_node, './ns:PstlAdr/ns:AdrLine')
322+ if address_node is not None:
323+ vals['remote_owner_address'] = [address_node.text]
324+ if account_node is not None:
325+ iban_node = self.find(account_node, './ns:IBAN')
326+ if iban_node is not None:
327+ vals['remote_account'] = iban_node.text
328+ if bic_node is not None:
329+ vals['remote_bank_bic'] = bic_node.text
330+ else:
331+ domestic_node = self.find(account_node, './ns:Othr/ns:Id')
332+ vals['remote_account'] = (
333+ domestic_node.text if domestic_node is not None else False)
334+ return vals
335+
336+ def parse_TxDtls(self, TxDtls, entry_values):
337+ """
338+ Parse a single TxDtls node
339+ """
340+ vals = dict(entry_values)
341+ unstructured = self.xpath(TxDtls, './ns:RmtInf/ns:Ustrd')
342+ if unstructured:
343+ vals['message'] = ' '.join([x.text for x in unstructured])
344+ structured = self.find(
345+ TxDtls, './ns:RmtInf/ns:Strd/ns:CdtrRefInf/ns:Ref')
346+ if structured is None or not structured.text:
347+ structured = self.find(TxDtls, './ns:Refs/ns:EndToEndId')
348+ if structured is not None:
349+ vals['reference'] = structured.text
350+ else:
351+ if vals.get('message'):
352+ vals['reference'] = vals['message']
353+ vals.update(self.get_party_values(TxDtls))
354+ return vals
355+
356+ def check_version(self):
357+ """
358+ Sanity check the document's namespace
359+ """
360+ if not self.ns.startswith('{urn:iso:std:iso:20022:tech:xsd:camt.'):
361+ raise except_orm(
362+ "Error",
363+ "This does not seem to be a CAMT format bank statement.")
364+
365+ if not self.ns.startswith('{urn:iso:std:iso:20022:tech:xsd:camt.053.'):
366+ raise except_orm(
367+ "Error",
368+ "Only CAMT.053 is supported at the moment.")
369+ return True
370+
371+ def parse(self, cr, data):
372+ """
373+ Parse a CAMT053 XML file
374+ """
375+ root = etree.fromstring(data)
376+ self.ns = root.tag[:root.tag.index("}") + 1]
377+ self.check_version()
378+ self.assert_tag(root[0][0], 'GrpHdr')
379+ statements = []
380+ for node in root[0][1:]:
381+ statements.append(self.parse_Stmt(cr, node))
382+ return statements

Subscribers

People subscribed via source and target branches