Merge lp:~therp-nl/banking-addons/6.1-add_camt_import into lp:banking-addons/6.1
- 6.1-add_camt_import
- Merge into 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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Holger Brunn (Therp) | code review | Approve | |
Review via email:
|
Commit message
[ADD] Support for SEPA CAMT.053 bank statements
Description of the change
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 |
see https:/ /code.launchpad .net/~therp- nl/banking- addons/ 7.0-add_ camt_import/ +merge/ 190638/ comments/ 438449