diff --git a/account_ebics/README.rst b/account_ebics/README.rst new file mode 100644 index 0000000..634a617 --- /dev/null +++ b/account_ebics/README.rst @@ -0,0 +1,191 @@ +.. image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: https://www.gnu.org/licenses/lpgl + :alt: License: LGPL-3 + +====================== +EBICS banking protocol +====================== + +Implementation of the EBICS banking protocol. + +This module facilitates the exchange of files with banks via the EBICS protocol. + +| + +Installation +============ + +The module depends upon + +- https://pypi.python.org/pypi/fintech +- https://pypi.python.org/pypi/cryptography + +Remark: + +The EBICS 'Test Mode' for uploading orders requires Fintech 4.3.4 or higher. + +SWIFT 3SKey support requires Fintech 6.4 or higher. +| + +We also recommend to consider the installation of the following modules: + +| + +- account_ebics_oe + + Required if you are running Odoo Enterprise + +| + +- account_ebics_batch_payment + + Recommended if you are using the Odoo Enterprise account_batch_payment module + +| + +- account_ebics_payment_order + + Recommended if you are using the OCA account_payment_order module. + + Cf. https://github.com/OCA/bank-payment + +| + +- account_statement_import_fr_cfonb + + Required to handle french CFONB files. + + Cf. https://github.com/OCA/l10n_france + +| + +- account_statement_import_camt + + Required to handle camt.052 and camt.054 files. + + Cf. https://github.com/OCA/bank_statement_import + +| + +- account_statement_import_helper + + Required if you are processing bank statements with local bank account numbers (e.g. french CFONB files) + and using import parsers based upon the OCA account_statement_import module. + + The import helper will match the local bank account number with the IBAN number specified on the Odoo Financial journal. + + Cf. https://github.com/Noviat/noviat-apps + +| + +- account_bank_statement_import_helper + + Required if you are processing bank statements with local bank account numbers + and using import parsers based upon the Odoo Enterprise account_bank_statement_import module. + + The import helper will match the local bank account number with the IBAN number specified on the Odoo Financial journal. + + Cf. https://github.com/Noviat/noviat-apps + +| + +Fintech license +--------------- + +If you have a valid Fintech.ebics license, you should add the following +licensing parameters to the odoo server configuration file: + + +- fintech_register_name + +The name of the licensee. + +- fintech_register_keycode + +The keycode of the licensed version. + +- fintech_register_users + +The licensed EBICS user ids. It must be a string or a list of user ids. + +You should NOT specify this parameter if your license is subsciption +based (with monthly recurring billing). + +| +| Example: +| + +:: + + ; fintech + fintech_register_name = MyCompany + fintech_register_keycode = AB1CD-E2FG-3H-IJ4K-5L + fintech_register_users = USER1, USER2 + +| + +Configuration +============= + +Go to **Settings > Users** + +Add the users that are authorised to maintain the EBICS configuration to the 'EBICS Manager' Group. + +| + +Go to **Accounting > Configuration > Miscellaneous > EBICS > EBICS File Formats** + +Check if the EBICS File formats that you want to process in Odoo are defined. + +Most commonly used formats for which support is available in Odoo should be there already. + +Please open an issue on https://github.com/Noviat/account_ebics to report missing EBICS File Formats. + +For File Formats of type 'Downloads' you can also specifiy a 'Download Process Method'. + +This is the method that will be executed when hitting the 'Process' button on the downloaded file. + +The following methods are currently available: + +- cfonb120 +- camt.053 +- camt.052 +- camt.054 + +All these methods require complimentary modules to be installed (cf. Installation section supra). + +You'll get an error message when the required module is not installed on your Odoo instance. + +| + +Go to **Accounting > Configuration > Miscellaneous > EBICS > EBICS Configuration** + +Configure your EBICS configuration according to the contract with your bank. + +| + +Usage +===== + +Go to **Accounting > Bank and Cash > EBICS Processing** + +| + +EBICS Return Codes +------------------ + +During the processing of your EBICS upload/download, your bank may return an Error Code, e.g. + +EBICS Functional Error: +EBICS_NO_DOWNLOAD_DATA_AVAILABLE (code: 90005) + +A detailled explanation of the codes can be found on http://www.ebics.org. +You can also find this information in the doc folder of this module (file EBICS_Annex1_ReturnCodes). + +| + +Known Issues / Roadmap +====================== + +- add support for EBICS 3.0 +- add support to import externally generated keys & certificates (currently only 3SKey signature certificate) diff --git a/account_ebics/__init__.py b/account_ebics/__init__.py new file mode 100644 index 0000000..168985a --- /dev/null +++ b/account_ebics/__init__.py @@ -0,0 +1,9 @@ +import logging + +_logger = logging.getLogger(__name__) + +try: + from . import models + from . import wizards +except Exception: + _logger.warning("Import Error, check if fintech lib has been installed") diff --git a/account_ebics/__manifest__.py b/account_ebics/__manifest__.py new file mode 100644 index 0000000..50cbd7a --- /dev/null +++ b/account_ebics/__manifest__.py @@ -0,0 +1,32 @@ +# Copyright 2009-2021 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +{ + 'name': 'EBICS banking protocol', + 'version': '14.0.1.0.0', + 'license': 'LGPL-3', + 'author': 'Noviat', + 'website': 'www.noviat.com', + 'category': 'Accounting & Finance', + 'depends': ['account'], + 'data': [ + 'security/ebics_security.xml', + 'security/ir.model.access.csv', + 'data/ebics_file_format.xml', + 'views/ebics_config_views.xml', + 'views/ebics_file_views.xml', + 'views/ebics_userid_views.xml', + 'views/ebics_file_format_views.xml', + 'wizards/ebics_change_passphrase.xml', + 'wizards/ebics_xfer.xml', + 'views/menu.xml', + ], + 'installable': True, + 'application': True, + 'external_dependencies': { + 'python': [ + 'fintech', + 'cryptography', + ] + }, +} diff --git a/account_ebics/data/ebics_file_format.xml b/account_ebics/data/ebics_file_format.xml new file mode 100644 index 0000000..e720900 --- /dev/null +++ b/account_ebics/data/ebics_file_format.xml @@ -0,0 +1,154 @@ + + + + + + + + camt.052 + down + C52 + camt.052 + bank to customer account report in format camt.052 + c52.xml + + + + camt.052 + down + Z52 + camt.052 + bank to customer account report in format camt.052 + c52.xml + + + + camt.053 + down + C53 + camt.053 + Bank to customer statement report in format camt.053 + c53.xml + + + + camt.053 + down + Z53 + camt.053 + Bank to customer statement report in format camt.053 + c53.xml + + + + camt.054 + down + C54 + camt.054 + Bank to customer debit credit notification in format camt.054 + c52.xml + + + + camt.054 + down + Z54 + camt.054 + Bank to customer debit credit notification in format camt.054 + c52.xml + + + + camt.xxx.cfonb120.stm + down + FDL + cfonb120 + Bank to customer statement report in format cfonb120 + cfonb120.dat + + + + pain.002 + down + CDZ + Payment status report for direct debit in format pain.002 + psr.xml + + + + pain.002 + down + Z01 + pain.002 + Payment status report for direct debit in format pain.002 + psr.xml + + + + + + pain.xxx.cfonb160.dco + up + FUL + Remises de LCR + txt + + + + pain.001.001.03 + up + CCT + Payment Order in format pain.001.001.03 + xml + + + + pain.001.001.03 + up + XE2 + Payment Order in format pain.001.001.03 + xml + + + + pain.008.001.02.sdd + up + CDD + Sepa Core Direct Debit Order in format pain.008.001.02 + xml + + + + pain.008.001.02.sdd + up + XE3 + Sepa Core Direct Debit Order in format pain.008.001.02 + xml + + + + pain.008.001.02.sbb + up + CDB + Sepa Direct Debit (B2B) Order in format pain.008.001.02 + xml + + + + pain.008.001.02.sbb + up + XE4 + Sepa Direct Debit (B2B) Order in format pain.008.001.02 + xml + + + + pain.001.001.02.sct + up + FUL + Payment Order in format pain.001.001.02 + xml + + + + diff --git a/account_ebics/doc/EBICS_Annex1_ReturnCodes_final-16-05-2011.pdf b/account_ebics/doc/EBICS_Annex1_ReturnCodes_final-16-05-2011.pdf new file mode 100644 index 0000000..2e54c4e Binary files /dev/null and b/account_ebics/doc/EBICS_Annex1_ReturnCodes_final-16-05-2011.pdf differ diff --git a/account_ebics/doc/EBICS_Annex2_OrderTypes-File_Formats-15-04-2016.pdf b/account_ebics/doc/EBICS_Annex2_OrderTypes-File_Formats-15-04-2016.pdf new file mode 100644 index 0000000..ffcc3ee Binary files /dev/null and b/account_ebics/doc/EBICS_Annex2_OrderTypes-File_Formats-15-04-2016.pdf differ diff --git a/account_ebics/doc/EBICS_Common_IG_based_EBICS_2.5.pdf b/account_ebics/doc/EBICS_Common_IG_based_EBICS_2.5.pdf new file mode 100644 index 0000000..53f3017 Binary files /dev/null and b/account_ebics/doc/EBICS_Common_IG_based_EBICS_2.5.pdf differ diff --git a/account_ebics/doc/EBICS_Specification_2.5_final-16-05-2011.pdf b/account_ebics/doc/EBICS_Specification_2.5_final-16-05-2011.pdf new file mode 100644 index 0000000..cada02a Binary files /dev/null and b/account_ebics/doc/EBICS_Specification_2.5_final-16-05-2011.pdf differ diff --git a/account_ebics/migrations/13.0.1.1/noupdate_changes.xml b/account_ebics/migrations/13.0.1.1/noupdate_changes.xml new file mode 100644 index 0000000..ae3cbe1 --- /dev/null +++ b/account_ebics/migrations/13.0.1.1/noupdate_changes.xml @@ -0,0 +1,18 @@ + + + + + EBICS Configuration model company rule + + + ['|', ('company_ids', '=', False), ('company_ids', 'in', user.company_ids.ids)] + + + + EBICS File model company rule + + + ['|', ('company_ids', '=', False), ('company_ids', 'in', user.company_ids.ids)] + + + diff --git a/account_ebics/migrations/13.0.1.1/post-migration.py b/account_ebics/migrations/13.0.1.1/post-migration.py new file mode 100644 index 0000000..a34c97d --- /dev/null +++ b/account_ebics/migrations/13.0.1.1/post-migration.py @@ -0,0 +1,73 @@ +# Copyright 2009-2020 Noviat. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from openupgradelib import openupgrade # pylint: disable=W7936 +import os + + +@openupgrade.migrate() +def migrate(env, version): + + _ebics_config_upgrade(env, version) + _noupdate_changes(env, version) + + +def _ebics_config_upgrade(env, version): + env.cr.execute("SELECT * FROM ebics_config") + cfg_datas = env.cr.dictfetchall() + for cfg_data in cfg_datas: + cfg = env['ebics.config'].browse(cfg_data['id']) + journal = env['account.journal'].search( + [('bank_account_id', '=', cfg_data['bank_id'])]) + keys_fn_old = cfg_data['ebics_keys'] + ebics_keys_root = os.path.dirname(keys_fn_old) + if os.path.isfile(keys_fn_old): + keys_fn = ebics_keys_root + '/' + cfg_data['ebics_user'] + '_keys' + os.rename(keys_fn_old, keys_fn) + state = cfg_data['state'] == 'active' and 'confirm' or 'draft' + cfg.write({ + 'company_ids': [(6, 0, [cfg_data['company_id']])], + 'journal_ids': [(6, 0, [journal.id])], + 'ebics_keys': ebics_keys_root, + 'state': state, + }) + + user_vals = { + 'ebics_config_id': cfg_data['id'], + 'name': cfg_data['ebics_user'], + } + for fld in [ + 'signature_class', 'ebics_passphrase', + 'ebics_ini_letter_fn', 'ebics_public_bank_keys_fn', + 'ebics_key_x509', 'ebics_key_x509_dn_cn', + 'ebics_key_x509_dn_o', 'ebics_key_x509_dn_ou', + 'ebics_key_x509_dn_c', 'ebics_key_x509_dn_st', + 'ebics_key_x509_dn_l', 'ebics_key_x509_dn_e', + 'ebics_file_format_ids', 'state']: + if cfg_data.get(fld): + if fld == 'ebics_file_format_ids': + user_vals[fld] = [(6, 0, cfg_data[fld])] + elif fld == 'state' and cfg_data['state'] == 'active': + user_vals['state'] = 'active_keys' + else: + user_vals[fld] = cfg_data[fld] + ebics_userid = env['ebics.userid'].create(user_vals) + env.cr.execute( + """ + UPDATE ir_attachment + SET res_model = 'ebics.userid', res_id = %s + WHERE name in ('ebics_ini_letter', 'ebics_public_bank_keys'); + """ + % ebics_userid.id) + + if len(cfg_datas) == 1: + env.cr.execute( + "UPDATE ebics_file SET ebics_userid_id = %s" % ebics_userid.id) + + +def _noupdate_changes(env, version): + openupgrade.load_data( + env.cr, + 'account_ebics', + 'migrations/13.0.1.1/noupdate_changes.xml' + ) diff --git a/account_ebics/migrations/13.0.1.1/pre-migration.py b/account_ebics/migrations/13.0.1.1/pre-migration.py new file mode 100644 index 0000000..53b714f --- /dev/null +++ b/account_ebics/migrations/13.0.1.1/pre-migration.py @@ -0,0 +1,9 @@ +# Copyright 2009-2020 Noviat. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +def migrate(cr, version): + if not version: + return + + cr.execute("DELETE FROM ebics_xfer;") diff --git a/account_ebics/migrations/13.0.1.3/post-migration.py b/account_ebics/migrations/13.0.1.3/post-migration.py new file mode 100644 index 0000000..a082120 --- /dev/null +++ b/account_ebics/migrations/13.0.1.3/post-migration.py @@ -0,0 +1,41 @@ +# Copyright 2009-2020 Noviat. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +_FILE_FORMATS = [ + {'xml_id_name': 'ebics_ff_C52', + 'download_process_method': 'camt.052', + }, + {'xml_id_name': 'ebics_ff_C53', + 'download_process_method': 'camt.053', + }, + {'xml_id_name': 'ebics_ff_FDL_camt_xxx_cfonb120_stm', + 'download_process_method': 'cfonb120', + }, + +] + + +def migrate(cr, version): + for ff in _FILE_FORMATS: + _update_file_format(cr, ff) + + +def _update_file_format(cr, ff): + cr.execute( + """ + SELECT res_id FROM ir_model_data + WHERE module='account_ebics' AND name='{}' + """.format(ff['xml_id_name']) + ) + res = cr.fetchone() + if res: + cr.execute( + """ + UPDATE ebics_file_format + SET download_process_method='{download_process_method}' + WHERE id={ff_id}; + """.format( + download_process_method=ff['download_process_method'], + ff_id=res[0] + ) + ) diff --git a/account_ebics/migrations/13.0.1.3/pre-migration.py b/account_ebics/migrations/13.0.1.3/pre-migration.py new file mode 100644 index 0000000..7799b26 --- /dev/null +++ b/account_ebics/migrations/13.0.1.3/pre-migration.py @@ -0,0 +1,70 @@ +# Copyright 2009-2020 Noviat. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +_FILE_FORMATS = [ + {'old_xml_id_name': 'ebics_ff_camt_052_001_02_stm', + 'new_xml_id_name': 'ebics_ff_C52', + 'new_name': 'camt.052', + }, + {'old_xml_id_name': 'ebics_ff_camt_053_001_02_stm', + 'new_xml_id_name': 'ebics_ff_C53', + 'new_name': 'camt.053', + }, + {'old_xml_id_name': 'ebics_ff_camt_xxx_cfonb120_stm', + 'new_xml_id_name': 'ebics_ff_FDL_camt_xxx_cfonb120_stm', + }, + {'old_xml_id_name': 'ebics_ff_pain_001_001_03_sct', + 'new_xml_id_name': 'ebics_ff_CCT', + }, + {'old_xml_id_name': 'ebics_ff_pain_001', + 'new_xml_id_name': 'ebics_ff_XE2', + 'new_name': 'pain.001.001.03', + }, + {'old_xml_id_name': 'ebics_ff_pain_008_001_02_sdd', + 'new_xml_id_name': 'ebics_ff_CDD', + }, + {'old_xml_id_name': 'ebics_ff_pain_008', + 'new_xml_id_name': 'ebics_ff_XE3', + }, + {'old_xml_id_name': 'ebics_ff_pain_008_001_02_sbb', + 'new_xml_id_name': 'ebics_ff_CDB', + }, + {'old_xml_id_name': 'ebics_ff_pain_001_001_02_sct', + 'new_xml_id_name': 'ebics_ff_FUL_pain_001_001_02_sct', + }, +] + + +def migrate(cr, version): + if not version: + return + + for ff in _FILE_FORMATS: + _update_file_format(cr, ff) + + +def _update_file_format(cr, ff): + cr.execute( + """ + SELECT id, res_id FROM ir_model_data + WHERE module='account_ebics' AND name='{}' + """.format(ff['old_xml_id_name']) + ) + res = cr.fetchone() + if res: + query = """ + UPDATE ir_model_data + SET name='{new_xml_id_name}' + WHERE id={xml_id}; + """.format( + new_xml_id_name=ff["new_xml_id_name"], xml_id=res[0] + ) + if ff.get('new_name'): + query += """ + UPDATE ebics_file_format + SET name='{new_name}' + WHERE id={ff_id}; + """.format( + new_name=ff["new_name"], ff_id=res[1] + ) + cr.execute(query) diff --git a/account_ebics/models/__init__.py b/account_ebics/models/__init__.py new file mode 100644 index 0000000..0a211c1 --- /dev/null +++ b/account_ebics/models/__init__.py @@ -0,0 +1,6 @@ +from . import fintech_ebics_register +from . import account_bank_statement +from . import ebics_config +from . import ebics_file +from . import ebics_file_format +from . import ebics_userid diff --git a/account_ebics/models/account_bank_statement.py b/account_ebics/models/account_bank_statement.py new file mode 100644 index 0000000..dde134d --- /dev/null +++ b/account_ebics/models/account_bank_statement.py @@ -0,0 +1,11 @@ +# Copyright 2009-2020 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +from odoo import fields, models + + +class AccountBankStatement(models.Model): + _inherit = 'account.bank.statement' + + ebics_file_id = fields.Many2one( + comodel_name='ebics.file', string='EBICS Data File') diff --git a/account_ebics/models/ebics_config.py b/account_ebics/models/ebics_config.py new file mode 100644 index 0000000..8c616c7 --- /dev/null +++ b/account_ebics/models/ebics_config.py @@ -0,0 +1,197 @@ +# Copyright 2009-2020 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +import logging +import re +import os + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class EbicsConfig(models.Model): + """ + EBICS configuration is stored in a separate object in order to + allow extra security policies on this object. + """ + _name = 'ebics.config' + _description = 'EBICS Configuration' + _order = 'name' + + name = fields.Char( + string='Name', + readonly=True, states={'draft': [('readonly', False)]}, + required=True) + journal_ids = fields.Many2many( + comodel_name='account.journal', + readonly=True, states={'draft': [('readonly', False)]}, + string='Bank Accounts', + domain="[('type', '=', 'bank')]", + required=True) + ebics_host = fields.Char( + string='EBICS HostID', required=True, + readonly=True, states={'draft': [('readonly', False)]}, + help="Contact your bank to get the EBICS HostID." + "\nIn France the BIC is usually allocated to the HostID " + "whereas in Germany it tends to be an institute specific string " + "of 8 characters.") + ebics_url = fields.Char( + string='EBICS URL', required=True, + readonly=True, states={'draft': [('readonly', False)]}, + help="Contact your bank to get the EBICS URL.") + ebics_version = fields.Selection( + selection=[('H003', 'H003 (2.4)'), + ('H004', 'H004 (2.5)')], + string='EBICS protocol version', + readonly=True, states={'draft': [('readonly', False)]}, + required=True, default='H004') + ebics_partner = fields.Char( + string='EBICS PartnerID', required=True, + readonly=True, states={'draft': [('readonly', False)]}, + help="Organizational unit (company or individual) " + "that concludes a contract with the bank. " + "\nIn this contract it will be agreed which order types " + "(file formats) are used, which accounts are concerned, " + "which of the customer's users (subscribers) " + "communicate with the EBICS bank server and the authorisations " + "that these users will possess. " + "\nIt is identified by the PartnerID.") + ebics_userid_ids = fields.One2many( + comodel_name='ebics.userid', + inverse_name='ebics_config_id', + readonly=True, states={'draft': [('readonly', False)]}, + help="Human users or a technical system that is/are " + "assigned to a customer. " + "\nOn the EBICS bank server it is identified " + "by the combination of UserID and PartnerID. " + "The technical subscriber serves only for the data exchange " + "between customer and financial institution. " + "The human user also can authorise orders.") + ebics_files = fields.Char( + string='EBICS Files Root', required=True, + readonly=True, states={'draft': [('readonly', False)]}, + default=lambda self: self._default_ebics_files(), + help="Root Directory for EBICS File Transfer Folders.") + # We store the EBICS keys in a separate directory in the file system. + # This directory requires special protection to reduce fraude. + ebics_keys = fields.Char( + string='EBICS Keys Root', required=True, + readonly=True, states={'draft': [('readonly', False)]}, + default=lambda self: self._default_ebics_keys(), + help="Root Directory for storing the EBICS Keys.") + ebics_key_version = fields.Selection( + selection=[('A005', 'A005 (RSASSA-PKCS1-v1_5)'), + ('A006', 'A006 (RSASSA-PSS)')], + string='EBICS key version', + default='A006', + readonly=True, states={'draft': [('readonly', False)]}, + help="The key version of the electronic signature.") + ebics_key_bitlength = fields.Integer( + string='EBICS key bitlength', + default=2048, + readonly=True, states={'draft': [('readonly', False)]}, + help="The bit length of the generated keys. " + "\nThe value must be between 1536 and 4096.") + ebics_file_format_ids = fields.Many2many( + comodel_name='ebics.file.format', + column1='config_id', column2='format_id', + string='EBICS File Formats', + readonly=True, states={'draft': [('readonly', False)]}, + ) + state = fields.Selection( + [('draft', 'Draft'), + ('confirm', 'Confirmed')], + string='State', + default='draft', + required=True, readonly=True) + order_number = fields.Char( + size=4, readonly=True, states={'draft': [('readonly', False)]}, + help="Specify the number for the next order." + "\nThis number should match the following pattern : " + "[A-Z]{1}[A-Z0-9]{3}") + active = fields.Boolean( + string='Active', default=True) + company_ids = fields.Many2many( + comodel_name='res.company', + string='Companies', + required=True, + help="Companies sharing this EBICS contract.") + + @api.model + def _default_ebics_files(self): + return '/'.join(['/home/odoo/ebics_files', self._cr.dbname]) + + @api.model + def _default_ebics_keys(self): + return '/'.join(['/etc/odoo/ebics_keys', self._cr.dbname]) + + @api.constrains('order_number') + def _check_order_number(self): + for cfg in self: + nbr = cfg.order_number + ok = True + if nbr: + if len(nbr) != 4: + ok = False + else: + pattern = re.compile("[A-Z]{1}[A-Z0-9]{3}") + if not pattern.match(nbr): + ok = False + if not ok: + raise UserError(_( + "Order Number should comply with the following pattern:" + "\n[A-Z]{1}[A-Z0-9]{3}")) + + @api.onchange('journal_ids') + def _onchange_journal_ids(self): + self.company_ids = self.journal_ids.mapped('company_id') + + def unlink(self): + for ebics_config in self: + if ebics_config.state == 'active': + raise UserError(_( + "You cannot remove active EBICS configurations.")) + return super(EbicsConfig, self).unlink() + + def set_to_draft(self): + return self.write({'state': 'draft'}) + + def set_to_confirm(self): + return self.write({'state': 'confirm'}) + + def _get_order_number(self): + return self.order_number + + def _update_order_number(self, OrderID): + o_list = list(OrderID) + for i, c in enumerate(reversed(o_list), start=1): + if c == '9': + o_list[-i] = 'A' + break + if c == 'Z': + continue + else: + o_list[-i] = chr(ord(c) + 1) + break + next = ''.join(o_list) + if next == 'ZZZZ': + next = 'A000' + self.order_number = next + + def _check_ebics_keys(self): + dirname = self.ebics_keys or '' + if not os.path.exists(dirname): + raise UserError(_( + "EBICS Keys Root Directory %s is not available." + "\nPlease contact your system administrator.") + % dirname) + + def _check_ebics_files(self): + dirname = self.ebics_files or '' + if not os.path.exists(dirname): + raise UserError(_( + "EBICS Files Root Directory %s is not available." + "\nPlease contact your system administrator.") + % dirname) diff --git a/account_ebics/models/ebics_file.py b/account_ebics/models/ebics_file.py new file mode 100644 index 0000000..ea3e4e8 --- /dev/null +++ b/account_ebics/models/ebics_file.py @@ -0,0 +1,426 @@ +# Copyright 2009-2021 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +import base64 +import logging + +from odoo import _, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class EbicsFile(models.Model): + _name = 'ebics.file' + _description = 'Object to store EBICS Data Files' + _order = 'date desc' + _sql_constraints = [ + ('name_uniq', 'unique (name, format_id)', + 'This File has already been down- or uploaded !') + ] + + name = fields.Char(string='Filename') + data = fields.Binary(string='File', readonly=True) + format_id = fields.Many2one( + comodel_name='ebics.file.format', + string='EBICS File Formats', + readonly=True) + type = fields.Selection( + related='format_id.type', + readonly=True) + date_from = fields.Date( + readonly=True, + help="'Date From' as entered in the download wizard.") + date_to = fields.Date( + readonly=True, + help="'Date To' as entered in the download wizard.") + date = fields.Datetime( + required=True, readonly=True, + help='File Upload/Download date') + bank_statement_ids = fields.One2many( + comodel_name='account.bank.statement', + inverse_name='ebics_file_id', + string='Generated Bank Statements', readonly=True) + state = fields.Selection( + [('draft', 'Draft'), + ('done', 'Done')], + string='State', + default='draft', + required=True, readonly=True) + user_id = fields.Many2one( + comodel_name='res.users', string='User', + default=lambda self: self.env.user, + readonly=True) + ebics_userid_id = fields.Many2one( + comodel_name='ebics.userid', + string='EBICS UserID', + ondelete='restrict', + readonly=True) + note = fields.Text(string='Notes') + note_process = fields.Text(string='Notes') + company_ids = fields.Many2many( + comodel_name='res.company', + string='Companies', + help="Companies sharing this EBICS file.") + + def unlink(self): + ff_methods = self._file_format_methods() + for ebics_file in self: + if ebics_file.state == 'done': + raise UserError(_( + "You can only remove EBICS files in state 'Draft'.")) + # execute format specific actions + ff = ebics_file.format_id.download_process_method + if ff in ff_methods: + if ff_methods[ff].get('unlink'): + ff_methods[ff]['unlink'](ebics_file) + # remove bank statements + ebics_file.bank_statement_ids.unlink() + return super(EbicsFile, self).unlink() + + def set_to_draft(self): + return self.write({'state': 'draft'}) + + def set_to_done(self): + return self.write({'state': 'done'}) + + def process(self): + self.ensure_one() + ctx = dict( + self.env.context, + allowed_company_ids=self.env.user.company_ids.ids) + self = self.with_context(ctx) + self.note_process = '' + ff_methods = self._file_format_methods() + ff = self.format_id.download_process_method + if ff in ff_methods: + if ff_methods[ff].get('process'): + res = ff_methods[ff]['process'](self) + self.state = 'done' + return res + else: + return self._process_undefined_format() + + def action_open_bank_statements(self): + self.ensure_one() + action = self.env['ir.actions.act_window']._for_xml_id( + 'account.action_bank_statement_tree') + domain = eval(action.get('domain') or '[]') + domain += [('id', 'in', self._context.get('statement_ids'))] + action.update({'domain': domain}) + return action + + def button_close(self): + self.ensure_one() + return {'type': 'ir.actions.act_window_close'} + + def _file_format_methods(self): + """ + Extend this dictionary in order to add support + for extra file formats. + """ + res = { + 'cfonb120': + {'process': self._process_cfonb120, + 'unlink': self._unlink_cfonb120}, + 'camt.052': + {'process': self._process_camt052, + 'unlink': self._unlink_camt052}, + 'camt.053': + {'process': self._process_camt053, + 'unlink': self._unlink_camt053}, + 'camt.054': + {'process': self._process_camt054, + 'unlink': self._unlink_camt054}, + 'pain.002': + {'process': self._process_pain002, + 'unlink': self._unlink_pain002}, + } + return res + + def _check_import_module(self, module, raise_if_not_found=True): + mod = self.env['ir.module.module'].sudo().search( + [('name', '=like', module), + ('state', '=', 'installed')]) + if not mod: + if raise_if_not_found: + raise UserError(_( + "The module to process the '%s' format is not installed " + "on your system. " + "\nPlease install module '%s'") + % (self.format_id.name, module)) + return False + return True + + def _process_result_action(self, res): + notifications = [] + st_line_ids = [] + statement_ids = [] + if res.get('context'): + notifications = res['context'].get('notifications', []) + st_line_ids = res['context'].get('statement_line_ids', []) + if notifications: + for notif in notifications: + parts = [] + for k in ['type', 'message', 'details']: + if notif.get(k): + msg = '%s: %s' % (k, notif[k]) + parts.append(msg) + self.note_process += '\n'.join(parts) + self.note_process += '\n' + self.note_process += '\n' + if st_line_ids: + self.flush() + self.env.cr.execute( + """ + SELECT DISTINCT + absl.statement_id, + abs.name, abs.date, abs.company_id, + rc.name AS company_name + FROM account_bank_statement_line absl + INNER JOIN account_bank_statement abs + ON abs.id = absl.statement_id + INNER JOIN res_company rc + ON rc.id = abs.company_id + WHERE absl.id IN %s + ORDER BY date, company_id + """, + (tuple(st_line_ids),) + ) + sts_data = self.env.cr.dictfetchall() + else: + sts_data = [] + st_cnt = len(sts_data) + if st_cnt: + self.note_process += _( + "%s bank statements have been imported: " + ) % st_cnt + self.note_process += '\n' + for st_data in sts_data: + self.note_process += ("\n%s, %s (%s)") % ( + st_data['date'], st_data['name'], st_data['company_name']) + statement_ids = [x['statement_id'] for x in sts_data] + if statement_ids: + self.sudo().bank_statement_ids = [(6, 0, statement_ids)] + ctx = dict(self.env.context, statement_ids=statement_ids) + module = __name__.split('addons.')[1].split('.')[0] + result_view = self.env.ref('%s.ebics_file_view_form_result' % module) + return { + 'name': _('Import EBICS File'), + 'res_id': self.id, + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': self._name, + 'view_id': result_view.id, + 'target': 'new', + 'context': ctx, + 'type': 'ir.actions.act_window', + } + + @staticmethod + def _process_cfonb120(self): + """ + We do not support the standard _journal_creation_wizard since a single + cfonb120 file may contain statements from different legal entities. + """ + import_module = 'account_statement_import_fr_cfonb' + self._check_import_module(import_module) + wiz_model = 'account.statement.import' + data_file = base64.b64decode(self.data) + lines = data_file.split(b'\n') + wiz_vals_list = [] + st_lines = b'' + transactions = False + for line in lines: + rec_type = line[0:2] + acc_number = line[21:32] + st_lines += line + b'\n' + if rec_type == b'04': + transactions = True + if rec_type == b'07': + if transactions: + fn = '_'.join([acc_number.decode(), self.name]) + wiz_vals_list.append({ + 'statement_filename': fn, + 'statement_file': base64.b64encode(st_lines) + }) + st_lines = b'' + transactions = False + result = { + 'type': 'ir.actions.client', + 'tag': 'bank_statement_reconciliation_view', + 'context': {'statement_line_ids': [], + 'company_ids': self.env.user.company_ids.ids, + 'notifications': []}, + } + wiz_ctx = dict(self.env.context, active_model='ebics.file') + for i, wiz_vals in enumerate(wiz_vals_list, start=1): + wiz = self.env[wiz_model].with_context(wiz_ctx).create(wiz_vals) + res = wiz.import_file_button() + ctx = res.get('context') + if (res.get('res_model') + == 'account.bank.statement.import.journal.creation'): + message = _( + "Error detected while importing statement number %s.\n" + ) % i + message += _("No financial journal found.") + details = _( + 'Bank account number: %s' + ) % ctx.get('default_bank_acc_number') + result['context']['notifications'].extend([{ + 'type': 'warning', + 'message': message, + 'details': details, + }]) + continue + result['context']['statement_line_ids'].extend( + ctx['statement_line_ids']) + result['context']['notifications'].extend( + ctx['notifications']) + return self._process_result_action(result) + + @staticmethod + def _unlink_cfonb120(self): + """ + Placeholder for cfonb120 specific actions before removing the + EBICS data file and its related bank statements. + """ + pass + + @staticmethod + def _process_camt052(self): + import_module = 'account_statement_import_camt' + self._check_import_module(import_module) + return self._process_camt053(self) + + @staticmethod + def _unlink_camt052(self): + """ + Placeholder for camt052 specific actions before removing the + EBICS data file and its related bank statements. + """ + pass + + @staticmethod + def _process_camt054(self): + import_module = 'account_statement_import_camt' + self._check_import_module(import_module) + return self._process_camt053(self) + + @staticmethod + def _unlink_camt054(self): + """ + Placeholder for camt054 specific actions before removing the + EBICS data file and its related bank statements. + """ + pass + + @staticmethod + def _process_camt053(self): + modules = [ + ('oca', 'account_statement_import_camt'), + ('oe', 'account_bank_statement_import_camt'), + ] + found = False + for src, mod in modules: + if self._check_import_module(mod, raise_if_not_found=False): + found = True + break + if not found: + raise UserError(_( + "The module to process the '%s' format is not installed " + "on your system. " + "\nPlease install one of the following modules: \n%s." + ) % (self.format_id.name, ', '.join([x[1] for x in modules])) + ) + if src == 'oca': + self._process_camt053_oca() + else: + self._process_camt053_oe() + + def _process_camt053_oca(self): + wiz_model = 'account.statement.import' + wiz_vals = { + 'statement_filename': self.name, + 'statement_file': self.data, + } + result = { + 'type': 'ir.actions.client', + 'tag': 'bank_statement_reconciliation_view', + 'context': {'statement_line_ids': [], + 'company_ids': self.env.user.company_ids.ids, + 'notifications': []}, + } + wiz_ctx = dict(self.env.context, active_model='ebics.file') + wiz = self.env[wiz_model].with_context(wiz_ctx).create(wiz_vals) + res = wiz.import_file_button() + ctx = res.get('context') + if (res.get('res_model') + == 'account.bank.statement.import.journal.creation'): + message = _( + "Error detected while importing statement %s.\n" + ) % self.name + message += _("No financial journal found.") + details = _( + 'Bank account number: %s' + ) % ctx.get('default_bank_acc_number') + result['context']['notifications'].extend([{ + 'type': 'warning', + 'message': message, + 'details': details, + }]) + result['context']['statement_line_ids'].extend( + ctx['statement_line_ids']) + result['context']['notifications'].extend( + ctx['notifications']) + return self._process_result_action(result) + + def _process_camt053_oe(self): + wiz_model = 'account.bank.statement.import' + wiz_vals = { + 'attachment_ids': [(0, 0, {'name': self.name, + 'datas': self.data, + 'store_fname': self.name})]} + ctx = dict(self.env.context, active_model='ebics.file') + wiz = self.env[wiz_model].with_context(ctx).create(wiz_vals) + res = wiz.import_file() + if res.get('res_model') \ + == 'account.bank.statement.import.journal.creation': + if res.get('context'): + bank_account = res['context'].get('default_bank_acc_number') + raise UserError(_( + "No financial journal found for Company Bank Account %s" + ) % bank_account) + return self._process_result_action(res) + + @staticmethod + def _unlink_camt053(self): + """ + Placeholder for camt053 specific actions before removing the + EBICS data file and its related bank statements. + """ + pass + + @staticmethod + def _process_pain002(self): + """ + Placeholder for processing pain.002 files. + TODO: + add import logic based upon OCA 'account_payment_return_import' + """ + pass + + @staticmethod + def _unlink_pain002(self): + """ + Placeholder for pain.002 specific actions before removing the + EBICS data file. + """ + raise NotImplementedError + + def _process_undefined_format(self): + raise UserError(_( + "The current version of the 'account_ebics' module " + "has no support to automatically process EBICS files " + "with format %s." + ) % self.format_id.name) diff --git a/account_ebics/models/ebics_file_format.py b/account_ebics/models/ebics_file_format.py new file mode 100644 index 0000000..5864644 --- /dev/null +++ b/account_ebics/models/ebics_file_format.py @@ -0,0 +1,62 @@ +# Copyright 2009-2020 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +from odoo import api, fields, models + + +class EbicsFileFormat(models.Model): + _name = 'ebics.file.format' + _description = 'EBICS File Formats' + _order = 'type,name,order_type' + + name = fields.Char( + string='Request Type', + required=True, + help="E.g. camt.xxx.cfonb120.stm, pain.001.001.03.sct.\n" + "Specify camt.052, camt.053, camt.054 for camt " + "Order Types such as C53, Z53, C54, Z54.\n" + "This name has to match the 'Request Type' in your " + "EBICS contract for Order Type 'FDL' or 'FUL'.\n") + type = fields.Selection( + selection=[('down', 'Download'), + ('up', 'Upload')], + required=True) + order_type = fields.Char( + string='Order Type', + required=True, + help="E.g. C53 (check your EBICS contract).\n" + "For most banks in France you should use the " + "format neutral Order Types 'FUL' for upload " + "and 'FDL' for download.") + download_process_method = fields.Selection( + selection='_selection_download_process_method', + help="Enable processing within Odoo of the downloaded file " + "via the 'Process' button." + "E.g. specify camt.053 to import a camt.053 file and create " + "a bank statement.") + # TODO: + # move signature_class parameter so that it can be set per EBICS config + signature_class = fields.Selection( + selection=[('E', 'Single signature'), + ('T', 'Transport signature')], + string='Signature Class', + help="Please doublecheck the security of your Odoo " + "ERP system when using class 'E' to prevent unauthorised " + "users to make supplier payments." + "\nLeave this field empty to use the default " + "defined for your EBICS UserID.") + description = fields.Char() + suffix = fields.Char( + required=True, + help="Specify the filename suffix for this File Format." + "\nE.g. c53.xml") + + @api.model + def _selection_download_process_method(self): + methods = self.env['ebics.file']._file_format_methods().keys() + return [(x, x) for x in methods] + + @api.onchange('type') + def _onchange_type(self): + if self.type == 'up': + self.download_process_method = False diff --git a/account_ebics/models/ebics_userid.py b/account_ebics/models/ebics_userid.py new file mode 100644 index 0000000..d3e8818 --- /dev/null +++ b/account_ebics/models/ebics_userid.py @@ -0,0 +1,466 @@ +# Copyright 2009-2020 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +import base64 +import logging +import os +from sys import exc_info +from traceback import format_exception +from urllib.error import URLError + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +""" +logging.basicConfig( + level=logging.DEBUG, + format='[%(asctime)s] %(levelname)s - %(name)s: %(message)s') +""" + + +try: + import fintech + from fintech.ebics import EbicsKeyRing, EbicsBank, EbicsUser,\ + EbicsClient, EbicsFunctionalError, EbicsTechnicalError + fintech.cryptolib = 'cryptography' +except ImportError: + _logger.warning('Failed to import fintech') + + +class EbicsBank(EbicsBank): + + def _next_order_id(self, partnerid): + """ + EBICS protocol version H003 requires generation of the OrderID. + The OrderID must be a string between 'A000' and 'ZZZZ' and + unique for each partner id. + """ + return hasattr(self, '_order_number') and self._order_number or 'A000' + + +class EbicsUserID(models.Model): + _name = 'ebics.userid' + _description = 'EBICS UserID' + _order = 'name' + + name = fields.Char( + string='EBICS UserID', required=True, + readonly=True, states={'draft': [('readonly', False)]}, + help="Human users or a technical system that is/are " + "assigned to a customer. " + "\nOn the EBICS bank server it is identified " + "by the combination of UserID and PartnerID. " + "The technical subscriber serves only for the data exchange " + "between customer and financial institution. " + "The human user also can authorise orders.") + ebics_config_id = fields.Many2one( + comodel_name='ebics.config', + string='EBICS Configuration', + ondelete='cascade') + user_ids = fields.Many2many( + comodel_name='res.users', + string='Users', + required=True, + help="Users who are allowed to use this EBICS UserID for " + " bank transactions.") + # Currently only a singe signature class per user is supported + # Classes A and B are not yet supported. + signature_class = fields.Selection( + selection=[('E', 'Single signature'), + ('T', 'Transport signature')], + string='Signature Class', + required=True, default='T', + readonly=True, states={'draft': [('readonly', False)]}, + help="Default signature class." + "This default can be overriden for specific " + "EBICS transactions (cf. File Formats).") + ebics_keys_fn = fields.Char( + compute='_compute_ebics_keys_fn') + ebics_keys_found = fields.Boolean( + compute='_compute_ebics_keys_found') + ebics_passphrase = fields.Char( + string='EBICS Passphrase') + ebics_ini_letter = fields.Binary( + string='EBICS INI Letter', readonly=True, + help="INI-letter PDF document to be sent to your bank.") + ebics_ini_letter_fn = fields.Char( + string='INI-letter Filename', readonly=True) + ebics_public_bank_keys = fields.Binary( + string='EBICS Public Bank Keys', readonly=True, + help="EBICS Public Bank Keys to be checked for consistency.") + ebics_public_bank_keys_fn = fields.Char( + string='EBICS Public Bank Keys Filename', readonly=True) + swift_3skey = fields.Boolean( + string='Enable 3SKey support', + help="Transactions for this user will be signed " + "by means of the SWIFT 3SKey token.") + swift_3skey_certificate = fields.Binary( + string='3SKey Certficate') + swift_3skey_certificate_fn = fields.Char( + string='EBICS Public Bank Keys Filename') + # X.509 Distinguished Name attributes used to + # create self-signed X.509 certificates + ebics_key_x509 = fields.Boolean( + string='X509 support', + help="Set this flag in order to work with " + "self-signed X.509 certificates") + ebics_key_x509_dn_cn = fields.Char( + string='Common Name [CN]', + readonly=True, states={'draft': [('readonly', False)]}, + ) + ebics_key_x509_dn_o = fields.Char( + string='Organization Name [O]', + readonly=True, states={'draft': [('readonly', False)]}, + ) + ebics_key_x509_dn_ou = fields.Char( + string='Organizational Unit Name [OU]', + readonly=True, states={'draft': [('readonly', False)]}, + ) + ebics_key_x509_dn_c = fields.Char( + string='Country Name [C]', + readonly=True, states={'draft': [('readonly', False)]}, + ) + ebics_key_x509_dn_st = fields.Char( + string='State Or Province Name [ST]', + readonly=True, states={'draft': [('readonly', False)]}, + ) + ebics_key_x509_dn_l = fields.Char( + string='Locality Name [L]', + readonly=True, states={'draft': [('readonly', False)]}, + ) + ebics_key_x509_dn_e = fields.Char( + string='Email Address', + readonly=True, states={'draft': [('readonly', False)]}, + ) + state = fields.Selection( + [('draft', 'Draft'), + ('init', 'Initialisation'), + ('get_bank_keys', 'Get Keys from Bank'), + ('to_verify', 'Verification'), + ('active_keys', 'Active Keys')], + string='State', + default='draft', + required=True, readonly=True) + active = fields.Boolean( + string='Active', default=True) + company_ids = fields.Many2many( + comodel_name='res.company', + string='Companies', + required=True, + help="Companies sharing this EBICS contract.") + + @api.depends('name') + def _compute_ebics_keys_fn(self): + for rec in self: + keys_dir = rec.ebics_config_id.ebics_keys + rec.ebics_keys_fn = ( + rec.name + and keys_dir + and (keys_dir + '/' + rec.name + '_keys')) + + @api.depends('ebics_keys_fn') + def _compute_ebics_keys_found(self): + for rec in self: + rec.ebics_keys_found = ( + rec.ebics_keys_fn + and os.path.isfile(rec.ebics_keys_fn) + ) + + @api.constrains('ebics_passphrase') + def _check_ebics_passphrase(self): + for rec in self: + if not rec.ebics_passphrase or len(rec.ebics_passphrase) < 8: + raise UserError(_( + "The passphrase must be at least 8 characters long")) + + @api.onchange('signature_class') + def _onchange_signature_class(self): + if self.signature_class == 'T': + self.swift_3skey = False + + @api.onchange('swift_3skey') + def _onchange_swift_3skey(self): + if self.swift_3skey: + self.ebics_key_x509 = True + + def set_to_draft(self): + return self.write({'state': 'draft'}) + + def set_to_active_keys(self): + return self.write({'state': 'active_keys'}) + + def set_to_get_bank_keys(self): + return self.write({'state': 'get_bank_keys'}) + + def ebics_init_1(self): + """ + Initialization of bank keys - Step 1: + Create new keys and certificates for this user + """ + self.ensure_one() + self.ebics_config_id._check_ebics_files() + if self.state != 'draft': + raise UserError( + _("Set state to 'draft' before Bank Key (re)initialisation.")) + + if not self.ebics_passphrase: + raise UserError( + _("Set a passphrase.")) + + if self.swift_3skey and not self.swift_3skey_certificate: + raise UserError( + _("3SKey certificate missing.")) + + ebics_version = self.ebics_config_id.ebics_version + try: + keyring = EbicsKeyRing( + keys=self.ebics_keys_fn, + passphrase=self.ebics_passphrase) + bank = EbicsBank( + keyring=keyring, + hostid=self.ebics_config_id.ebics_host, + url=self.ebics_config_id.ebics_url) + user = EbicsUser( + keyring=keyring, + partnerid=self.ebics_config_id.ebics_partner, + userid=self.name) + except Exception: + exctype, value = exc_info()[:2] + error = _("EBICS Initialisation Error:") + error += '\n' + str(exctype) + '\n' + str(value) + raise UserError(error) + + self.ebics_config_id._check_ebics_keys() + if not os.path.isfile(self.ebics_keys_fn): + try: + # TODO: + # enable import of all type of certicates: A00x, X002, E002 + if self.swift_3skey: + kwargs = { + self.ebics_config_id.ebics_key_version: + base64.decodestring(self.swift_3skey_certificate), + } + user.import_certificates(**kwargs) + user.create_keys( + keyversion=self.ebics_config_id.ebics_key_version, + bitlength=self.ebics_config_id.ebics_key_bitlength) + except Exception: + exctype, value = exc_info()[:2] + error = _("EBICS Initialisation Error:") + error += '\n' + str(exctype) + '\n' + str(value) + raise UserError(error) + + if self.swift_3skey and not self.ebics_key_x509: + raise UserError(_( + "The current version of this module " + "requires to X509 support when enabling 3SKey")) + + if self.ebics_key_x509: + dn_attrs = { + 'commonName': self.ebics_key_x509_dn_cn, + 'organizationName': self.ebics_key_x509_dn_o, + 'organizationalUnitName': self.ebics_key_x509_dn_ou, + 'countryName': self.ebics_key_x509_dn_c, + 'stateOrProvinceName': self.ebics_key_x509_dn_st, + 'localityName': self.ebics_key_x509_dn_l, + 'emailAddress': self.ebics_key_x509_dn_e, + } + kwargs = {k: v for k, v in dn_attrs.items() if v} + user.create_certificates(**kwargs) + + client = EbicsClient( + bank, user, version=ebics_version) + + # Send the public electronic signature key to the bank. + ebics_config_bank = self.ebics_config_id.journal_ids[0].bank_id + if not ebics_config_bank: + raise UserError(_( + "No bank defined for the financial journal " + "of the EBICS Config")) + try: + supported_versions = client.HEV() + if ebics_version not in supported_versions: + err_msg = _("EBICS version mismatch.") + "\n" + err_msg += _("Versions supported by your bank:") + for k in supported_versions: + err_msg += "\n%s: %s " % (k, supported_versions[k]) + raise UserError(err_msg) + if ebics_version == 'H003': + bank._order_number = self.ebics_config_id._get_order_number() + OrderID = client.INI() + _logger.info( + '%s, EBICS INI command, OrderID=%s', self._name, OrderID) + if ebics_version == 'H003': + self.ebics_config_id._update_order_number(OrderID) + except URLError: + exctype, value = exc_info()[:2] + tb = "".join(format_exception(*exc_info())) + _logger.error( + "EBICS INI command error\nUserID: %s\n%s", + self.name, + tb, + ) + raise UserError(_( + "urlopen error:\n url '%s' - %s") + % (self.ebics_config_id.ebics_url, str(value))) + except EbicsFunctionalError: + e = exc_info() + error = _("EBICS Functional Error:") + error += '\n' + error += '%s (code: %s)' % (e[1].message, e[1].code) + raise UserError(error) + except EbicsTechnicalError: + e = exc_info() + error = _("EBICS Technical Error:") + error += '\n' + error += '%s (code: %s)' % (e[1].message, e[1].code) + raise UserError(error) + + # Send the public authentication and encryption keys to the bank. + if ebics_version == 'H003': + bank._order_number = self.ebics_config_id._get_order_number() + OrderID = client.HIA() + _logger.info('%s, EBICS HIA command, OrderID=%s', self._name, OrderID) + if ebics_version == 'H003': + self.ebics_config_id._update_order_number(OrderID) + + # Create an INI-letter which must be printed and sent to the bank. + ebics_config_bank = self.ebics_config_id.journal_ids[0].bank_id + cc = ebics_config_bank.country.code + if cc in ['FR', 'DE']: + lang = cc + else: + lang = self.env.user.lang or \ + self.env['res.lang'].search([])[0].code + lang = lang[:2] + tmp_dir = os.path.normpath(self.ebics_config_id.ebics_files + '/tmp') + if not os.path.isdir(tmp_dir): + os.makedirs(tmp_dir, mode=0o700) + fn_date = fields.Date.today().isoformat() + fn = '_'.join( + [self.ebics_config_id.ebics_host, 'ini_letter', fn_date]) + '.pdf' + full_tmp_fn = os.path.normpath(tmp_dir + '/' + fn) + user.create_ini_letter( + bankname=ebics_config_bank.name, + path=full_tmp_fn, + lang=lang) + with open(full_tmp_fn, 'rb') as f: + letter = f.read() + self.write({ + 'ebics_ini_letter': base64.encodebytes(letter), + 'ebics_ini_letter_fn': fn, + }) + + return self.write({'state': 'init'}) + + def ebics_init_2(self): + """ + Initialization of bank keys - Step 2: + Activation of the account by the bank. + """ + if self.state != 'init': + raise UserError( + _("Set state to 'Initialisation'.")) + self.ensure_one() + return self.write({'state': 'get_bank_keys'}) + + def ebics_init_3(self): + """ + Initialization of bank keys - Step 3: + + After the account has been activated the public bank keys + must be downloaded and checked for consistency. + """ + self.ensure_one() + self.ebics_config_id._check_ebics_files() + if self.state != 'get_bank_keys': + raise UserError( + _("Set state to 'Get Keys from Bank'.")) + try: + keyring = EbicsKeyRing( + keys=self.ebics_keys_fn, passphrase=self.ebics_passphrase) + bank = EbicsBank( + keyring=keyring, + hostid=self.ebics_config_id.ebics_host, + url=self.ebics_config_id.ebics_url) + user = EbicsUser( + keyring=keyring, + partnerid=self.ebics_config_id.ebics_partner, + userid=self.name) + client = EbicsClient( + bank, user, version=self.ebics_config_id.ebics_version) + except Exception: + exctype, value = exc_info()[:2] + error = _("EBICS Initialisation Error:") + error += '\n' + str(exctype) + '\n' + str(value) + raise UserError(error) + + try: + public_bank_keys = client.HPB() + except EbicsFunctionalError: + e = exc_info() + error = _("EBICS Functional Error:") + error += '\n' + error += '%s (code: %s)' % (e[1].message, e[1].code) + raise UserError(error) + except Exception: + exctype, value = exc_info()[:2] + error = _("EBICS Initialisation Error:") + error += '\n' + str(exctype) + '\n' + str(value) + raise UserError(error) + + public_bank_keys = public_bank_keys.encode() + tmp_dir = os.path.normpath(self.ebics_config_id.ebics_files + '/tmp') + if not os.path.isdir(tmp_dir): + os.makedirs(tmp_dir, mode=0o700) + fn_date = fields.Date.today().isoformat() + fn = '_'.join( + [self.ebics_config_id.ebics_host, 'public_bank_keys', fn_date] + ) + '.txt' + self.write({ + 'ebics_public_bank_keys': base64.encodestring(public_bank_keys), + 'ebics_public_bank_keys_fn': fn, + 'state': 'to_verify', + }) + + return True + + def ebics_init_4(self): + """ + Initialization of bank keys - Step 2: + Confirm Verification of the public bank keys + and activate the bank keyu. + """ + self.ensure_one() + if self.state != 'to_verify': + raise UserError( + _("Set state to 'Verification'.")) + + keyring = EbicsKeyRing( + keys=self.ebics_keys_fn, passphrase=self.ebics_passphrase) + bank = EbicsBank( + keyring=keyring, + hostid=self.ebics_config_id.ebics_host, + url=self.ebics_config_id.ebics_url) + bank.activate_keys() + return self.write({'state': 'active_keys'}) + + def change_passphrase(self): + self.ensure_one() + ctx = dict(self._context, default_ebics_userid_id=self.id) + module = __name__.split('addons.')[1].split('.')[0] + view = self.env.ref( + '%s.ebics_change_passphrase_view_form' % module) + return { + 'name': _('EBICS keys change passphrase'), + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'ebics.change.passphrase', + 'view_id': view.id, + 'target': 'new', + 'context': ctx, + 'type': 'ir.actions.act_window', + } diff --git a/account_ebics/models/fintech_ebics_register.py b/account_ebics/models/fintech_ebics_register.py new file mode 100644 index 0000000..f908637 --- /dev/null +++ b/account_ebics/models/fintech_ebics_register.py @@ -0,0 +1,45 @@ +# Copyright 2009-2020 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +import logging +from sys import exc_info +from traceback import format_exception + +from odoo.tools import config + +_logger = logging.getLogger(__name__) + +try: + import fintech +except ImportError: + fintech = None + _logger.warning('Failed to import fintech') + +fintech_register_name = config.get('fintech_register_name') +fintech_register_keycode = config.get('fintech_register_keycode') +fintech_register_users = config.get('fintech_register_users') + +try: + if fintech: + fintech_register_users = ( + fintech_register_users + and [x.strip() for x in fintech_register_users.split(',')] + or None + ) + fintech.cryptolib = 'cryptography' + fintech.register( + name=fintech_register_name, + keycode=fintech_register_keycode, + users=fintech_register_users) +except RuntimeError as e: + if str(e) == "'register' can be called only once": + pass + else: + _logger.error(str(e)) + fintech.register() +except Exception: + msg = "fintech.register error" + tb = ''.join(format_exception(*exc_info())) + msg += '\n%s' % tb + _logger.error(msg) + fintech.register() diff --git a/account_ebics/security/ebics_security.xml b/account_ebics/security/ebics_security.xml new file mode 100644 index 0000000..fc939a2 --- /dev/null +++ b/account_ebics/security/ebics_security.xml @@ -0,0 +1,34 @@ + + + + + EBICS Manager + + + + + + + EBICS Configuration model company rule + + + ['|', ('company_ids', '=', False), ('company_ids', 'in', user.company_ids.ids)] + + + + EBICS UserID model company rule + + + ['|', ('company_ids', '=', False), ('company_ids', 'in', user.company_ids.ids)] + + + + EBICS File model company rule + + + ['|', ('company_ids', '=', False), ('company_ids', 'in', user.company_ids.ids)] + + + + + diff --git a/account_ebics/security/ir.model.access.csv b/account_ebics/security/ir.model.access.csv new file mode 100644 index 0000000..9c2ae17 --- /dev/null +++ b/account_ebics/security/ir.model.access.csv @@ -0,0 +1,12 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ebics_config_manager,ebics_config manager,model_ebics_config,group_ebics_manager,1,1,1,1 +access_ebics_config_user,ebics_config user,model_ebics_config,account.group_account_invoice,1,0,0,0 +access_ebics_userid_manager,ebics_userid manager,model_ebics_userid,group_ebics_manager,1,1,1,1 +access_ebics_userid_user,ebics_userid user,model_ebics_userid,account.group_account_invoice,1,0,0,0 +access_ebics_file_format_manager,ebics_file_format manager,model_ebics_file_format,group_ebics_manager,1,1,1,1 +access_ebics_file_format_user,ebics_file_format user,model_ebics_file_format,account.group_account_invoice,1,0,0,0 +access_ebics_file_manager,ebics_file manager,model_ebics_file,group_ebics_manager,1,1,1,1 +access_ebics_file_user,ebics_file user,model_ebics_file,account.group_account_invoice,1,1,1,0 + +access_ebics_change_passphrase,access_ebics_change_passphrase,model_ebics_change_passphrase,group_ebics_manager,1,1,1,0 +access_ebics_xfer,access_ebics_xfer,model_ebics_xfer,account.group_account_invoice,1,1,1,0 diff --git a/account_ebics/static/description/icon.png b/account_ebics/static/description/icon.png new file mode 100644 index 0000000..889d129 Binary files /dev/null and b/account_ebics/static/description/icon.png differ diff --git a/account_ebics/static/description/index.html b/account_ebics/static/description/index.html new file mode 100644 index 0000000..a2861eb --- /dev/null +++ b/account_ebics/static/description/index.html @@ -0,0 +1,513 @@ + + + + + + + + + + +
+ + +License: LGPL-3 +
+

EBICS banking protocol

+

Implementation of the EBICS banking protocol.

+

This module facilitates the exchange of files with banks via the EBICS protocol.

+
+

+
+
+

Installation

+

The module depends upon

+ +

Remark:

+

The EBICS 'Test Mode' for uploading orders requires Fintech 4.3.4 or higher.

+

SWIFT 3SKey support requires Fintech 6.4 or higher. +|

+

We also recommend to consider the installation of the following modules:

+
+

+
+
    +
  • account_ebics_oe

    +

    Required if you are running Odoo Enterprise

    +
  • +
+
+

+
+
    +
  • account_ebics_batch_payment

    +

    Recommended if you are using the Odoo Enterprise account_batch_payment module

    +
  • +
+
+

+
+ +
+

+
+ +
+

+
+ +
+

+
+
    +
  • account_bank_statement_import_helper

    +

    Required if you are processing bank statements with local bank account numbers (e.g. french CFONB files).

    +

    The import helper will match the local bank account number with the IBAN number specified on the Odoo Financial journal.

    +

    Cf. https://github.com/Noviat/noviat-apps

    +
  • +
+
+

+
+
+

Fintech license

+

If you have a valid Fintech.ebics license, you should add the following +licensing parameters to the odoo server configuration file:

+
    +
  • fintech_register_name
  • +
+

The name of the licensee.

+
    +
  • fintech_register_keycode
  • +
+

The keycode of the licensed version.

+
    +
  • fintech_register_users
  • +
+

The licensed EBICS user ids. It must be a string or a list of user ids.

+

You should NOT specify this parameter if your license is subsciption +based (with monthly recurring billing).

+
+

+
Example:
+

+
+
+; fintech
+fintech_register_name = MyCompany
+fintech_register_keycode = AB1CD-E2FG-3H-IJ4K-5L
+fintech_register_users = USER1, USER2
+
+
+

+
+
+
+
+

Configuration

+

Go to Settings > Users

+

Add the users that are authorised to maintain the EBICS configuration to the 'EBICS Manager' Group.

+
+

+
+

Go to Accounting > Configuration > Miscellaneous > EBICS > EBICS File Formats

+

Check if the EBICS File formats that you want to process in Odoo are defined.

+

Most commonly used formats for which support is available in Odoo should be there already.

+

Please open an issue on https://github.com/Noviat/account_ebics to report missing EBICS File Formats.

+

For File Formats of type 'Downloads' you can also specifiy a 'Download Process Method'.

+

This is the method that will be executed when hitting the 'Process' button on the downloaded file.

+

The following methods are currently available:

+
    +
  • cfonb120
  • +
  • camt.053
  • +
  • camt.052
  • +
  • camt.054
  • +
+

All these methods require complimentary modules to be installed (cf. Installation section supra).

+

You'll get an error message when the required module is not installed on your Odoo instance.

+
+

+
+

Go to Accounting > Configuration > Miscellaneous > EBICS > EBICS Configuration

+

Configure your EBICS configuration according to the contract with your bank.

+
+

+
+
+
+

Usage

+

Go to Accounting > Bank and Cash > EBICS Processing

+
+

+
+
+

EBICS Return Codes

+

During the processing of your EBICS upload/download, your bank may return an Error Code, e.g.

+

EBICS Functional Error: +EBICS_NO_DOWNLOAD_DATA_AVAILABLE (code: 90005)

+

A detailled explanation of the codes can be found on http://www.ebics.org. +You can also find this information in the doc folder of this module (file EBICS_Annex1_ReturnCodes).

+
+

+
+
+
+
+

Known Issues / Roadmap

+
    +
  • add support for EBICS 3.0
  • +
  • add support to import externally generated keys & certificates (currently only 3SKey signature certificate)
  • +
+
+
+
+ + diff --git a/account_ebics/views/ebics_config_views.xml b/account_ebics/views/ebics_config_views.xml new file mode 100644 index 0000000..f76c619 --- /dev/null +++ b/account_ebics/views/ebics_config_views.xml @@ -0,0 +1,74 @@ + + + + + ebics.config.tree + ebics.config + + + + + + + + + + + + ebics.config.form + ebics.config + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + EBICS Configuration + ebics.config + tree,form + {'active_test': False} + + +
diff --git a/account_ebics/views/ebics_file_format_views.xml b/account_ebics/views/ebics_file_format_views.xml new file mode 100644 index 0000000..79ed9e2 --- /dev/null +++ b/account_ebics/views/ebics_file_format_views.xml @@ -0,0 +1,50 @@ + + + + + ebics.file.format.tree + ebics.file.format + + + + + + + + + + + + + ebics.file.format.form + ebics.file.format + +
+ + + + + + + + + + + + + + + +
+
+
+ + + EBICS File Formats + ebics.file.format + tree,form + + +
diff --git a/account_ebics/views/ebics_file_views.xml b/account_ebics/views/ebics_file_views.xml new file mode 100644 index 0000000..a7376ae --- /dev/null +++ b/account_ebics/views/ebics_file_views.xml @@ -0,0 +1,199 @@ + + + + + ebics.file.search + ebics.file + + + + + + + + + + + + + + + + + + + + + + + + ebics.file.tree + ebics.file + + + + + + + + + + + + + + + + ebics.file.form + ebics.file + 1 + +
+
+
+ + + + + + + + + + + + + + + + + + + +
+
+
+ + + ebics.file.process.result + ebics.file + 2 + +
+ + +
+
+ +
+
+ + + EBICS Download Files + ir.actions.act_window + ebics.file + tree,form + + [('type','=','down')] + + + + + + tree + + + + + + + form + + + + + + + + ebics.file.tree + ebics.file + + + + + + + + + + + + + + ebics.file.form + ebics.file + 1 + +
+
+
+ + + + + + + + + + + + + + +
+
+
+ + + EBICS Upload Files + ir.actions.act_window + ebics.file + tree,form + + [('type','=','up')] + + + + + + tree + + + + + + + form + + + + +
diff --git a/account_ebics/views/ebics_userid_views.xml b/account_ebics/views/ebics_userid_views.xml new file mode 100644 index 0000000..aa32002 --- /dev/null +++ b/account_ebics/views/ebics_userid_views.xml @@ -0,0 +1,89 @@ + + + + + ebics.userid.tree + ebics.userid + + + + + + + + + + + + ebics.userid.form + ebics.userid + +
+
+
+ + + + + + + + + + + + + + + + + + + + Distinguished Name attributes used to create self-signed X.509 certificates: + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
diff --git a/account_ebics/views/menu.xml b/account_ebics/views/menu.xml new file mode 100644 index 0000000..a2a2c6a --- /dev/null +++ b/account_ebics/views/menu.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/account_ebics/wizards/__init__.py b/account_ebics/wizards/__init__.py new file mode 100644 index 0000000..b66b034 --- /dev/null +++ b/account_ebics/wizards/__init__.py @@ -0,0 +1,2 @@ +from . import ebics_change_passphrase +from . import ebics_xfer diff --git a/account_ebics/wizards/ebics_change_passphrase.py b/account_ebics/wizards/ebics_change_passphrase.py new file mode 100644 index 0000000..5321453 --- /dev/null +++ b/account_ebics/wizards/ebics_change_passphrase.py @@ -0,0 +1,75 @@ +# Copyright 2009-2020 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +import logging + +from odoo import _, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +try: + import fintech + from fintech.ebics import EbicsKeyRing + fintech.cryptolib = 'cryptography' +except ImportError: + _logger.warning('Failed to import fintech') + + +class EbicsChangePassphrase(models.TransientModel): + _name = 'ebics.change.passphrase' + _description = 'Change EBICS keys passphrase' + + ebics_userid_id = fields.Many2one( + comodel_name='ebics.userid', + string='EBICS UserID', + readonly=True) + old_pass = fields.Char( + string='Old Passphrase', + required=True) + new_pass = fields.Char( + string='New Passphrase', + required=True) + new_pass_check = fields.Char( + string='New Passphrase (verification)', + required=True) + note = fields.Text(string='Notes', readonly=True) + + def change_passphrase(self): + self.ensure_one() + if self.old_pass != self.ebics_userid_id.ebics_passphrase: + raise UserError(_( + "Incorrect old passphrase.")) + if self.new_pass != self.new_pass_check: + raise UserError(_( + "New passphrase verification error.")) + if self.new_pass == self.ebics_userid_id.ebics_passphrase: + raise UserError(_( + "New passphrase equal to old passphrase.")) + try: + keyring = EbicsKeyRing( + keys=self.ebics_userid_id.ebics_keys_fn, + passphrase=self.ebics_userid_id.ebics_passphrase) + keyring.change_passphrase(self.new_pass) + except ValueError as e: + raise UserError(str(e)) + self.ebics_userid.ebics_passphrase = self.new_pass + self.note = "The EBICS Passphrase has been changed." + + module = __name__.split('addons.')[1].split('.')[0] + result_view = self.env.ref( + '%s.ebics_change_passphrase_view_form_result' % module) + return { + 'name': _('EBICS Keys Change Passphrase'), + 'res_id': self.id, + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'ebics.change.passphrase', + 'view_id': result_view.id, + 'target': 'new', + 'type': 'ir.actions.act_window', + } + + def button_close(self): + self.ensure_one() + return {'type': 'ir.actions.act_window_close'} diff --git a/account_ebics/wizards/ebics_change_passphrase.xml b/account_ebics/wizards/ebics_change_passphrase.xml new file mode 100644 index 0000000..f0a33f5 --- /dev/null +++ b/account_ebics/wizards/ebics_change_passphrase.xml @@ -0,0 +1,39 @@ + + + + + EBICS Keys Change Passphrase + ebics.change.passphrase + 1 + +
+ + + + + +
+
+
+
+
+ + + EBICS Keys Change Passphrase + ebics.change.passphrase + 2 + +
+ + + + + +
+ +
diff --git a/account_ebics/wizards/ebics_xfer.py b/account_ebics/wizards/ebics_xfer.py new file mode 100644 index 0000000..d9c1c55 --- /dev/null +++ b/account_ebics/wizards/ebics_xfer.py @@ -0,0 +1,567 @@ +# Copyright 2009-2021 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +""" +import logging +logging.basicConfig( + level=logging.DEBUG, + format='[%(asctime)s] %(levelname)s - %(name)s: %(message)s') +""" + +import base64 +import logging +import os +from sys import exc_info +from traceback import format_exception + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +try: + import fintech + from fintech.ebics import EbicsKeyRing, EbicsBank, EbicsUser, EbicsClient,\ + EbicsFunctionalError, EbicsTechnicalError, EbicsVerificationError + fintech.cryptolib = 'cryptography' +except ImportError: + EbicsBank = object + _logger.warning('Failed to import fintech') + + +class EbicsBank(EbicsBank): + + def _next_order_id(self, partnerid): + """ + EBICS protocol version H003 requires generation of the OrderID. + The OrderID must be a string between 'A000' and 'ZZZZ' and + unique for each partner id. + """ + return hasattr(self, '_order_number') and self._order_number or 'A000' + + +class EbicsXfer(models.TransientModel): + _name = 'ebics.xfer' + _description = 'EBICS file transfer' + + ebics_config_id = fields.Many2one( + comodel_name='ebics.config', + string='EBICS Configuration', + domain=[('state', '=', 'confirm')], + default=lambda self: self._default_ebics_config_id()) + ebics_userid_id = fields.Many2one( + comodel_name='ebics.userid', + string='EBICS UserID') + ebics_passphrase = fields.Char( + string='EBICS Passphrase') + date_from = fields.Date() + date_to = fields.Date() + upload_data = fields.Binary(string='File to Upload') + upload_fname = fields.Char( + string='Upload Filename', default='') + upload_fname_dummy = fields.Char( + related='upload_fname', string='Upload Filename', readonly=True) + format_id = fields.Many2one( + comodel_name='ebics.file.format', + string='EBICS File Format', + help="Select EBICS File Format to upload/download." + "\nLeave blank to download all available files.") + allowed_format_ids = fields.Many2many( + related='ebics_config_id.ebics_file_format_ids', + string='Allowed EBICS File Formats') + order_type = fields.Char( + related='format_id.order_type', + string='Order Type', + help="For most banks in France you should use the " + "format neutral Order Types 'FUL' for upload " + "and 'FDL' for download.") + test_mode = fields.Boolean( + string='Test Mode', + help="Select this option to test if the syntax of " + "the upload file is correct." + "\nThis option is only available for " + "Order Type 'FUL'.") + note = fields.Text(string='EBICS file transfer Log', readonly=True) + + @api.model + def _default_ebics_config_id(self): + cfg_mod = self.env['ebics.config'] + cfg = cfg_mod.search( + [('company_ids', 'in', self.env.user.company_ids.ids), + ('state', '=', 'confirm')]) + if cfg and len(cfg) == 1: + return cfg + else: + return cfg_mod + + @api.onchange('ebics_config_id') + def _onchange_ebics_config_id(self): + ebics_userids = self.ebics_config_id.ebics_userid_ids + if self._context.get('ebics_download'): + download_formats = self.ebics_config_id.ebics_file_format_ids\ + .filtered(lambda r: r.type == 'down') + if len(download_formats) == 1: + self.format_id = download_formats + if len(ebics_userids) == 1: + self.ebics_userid_id = ebics_userids + else: + transport_users = ebics_userids.filtered( + lambda r: r.signature_class == 'T') + if len(transport_users) == 1: + self.ebics_userid_id = transport_users + else: + upload_formats = self.ebics_config_id.ebics_file_format_ids\ + .filtered(lambda r: r.type == 'up') + if len(upload_formats) == 1: + self.format_id = upload_formats + if len(ebics_userids) == 1: + self.ebics_userid_id = ebics_userids + + @api.onchange('upload_data') + def _onchange_upload_data(self): + self.upload_fname_dummy = self.upload_fname + self.format_id = False + self._detect_upload_format() + if not self.format_id: + upload_formats = self.format_id \ + or self.ebics_config_id.ebics_file_format_ids.filtered( + lambda r: r.type == 'up') + if len(upload_formats) > 1: + upload_formats = upload_formats.filtered( + lambda r: self.upload_fname.endswith(r.suffix)) + if len(upload_formats) == 1: + self.format_id = upload_formats + + @api.onchange('format_id') + def _onchange_format_id(self): + self.order_type = self.format_id.order_type + + def ebics_upload(self): + self.ensure_one() + ctx = self._context.copy() + ebics_file = self._ebics_upload() + if ebics_file: + ctx['ebics_file_id'] = ebics_file.id + module = __name__.split('addons.')[1].split('.')[0] + result_view = self.env.ref( + '%s.ebics_xfer_view_form_result' % module) + return { + 'name': _('EBICS file transfer result'), + 'res_id': self.id, + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'ebics.xfer', + 'view_id': result_view.id, + 'target': 'new', + 'context': ctx, + 'type': 'ir.actions.act_window', + } + + def ebics_download(self): + self.ensure_one() + self.ebics_config_id._check_ebics_files() + ctx = self._context.copy() + self.note = '' + client = self._setup_client() + if client: + download_formats = ( + self.format_id + or self.ebics_config_id.ebics_file_format_ids.filtered( + lambda r: r.type == 'down' + ) + ) + ebics_files = self.env['ebics.file'] + date_from = self.date_from and self.date_from.isoformat() or None + date_to = self.date_to and self.date_to.isoformat() or None + for df in download_formats: + try: + success = False + if df.order_type == 'FDL': + data = client.FDL(df.name, date_from, date_to) + else: + params = None + if date_from and date_to: + params = {'DateRange': { + 'Start': date_from, + 'End': date_to, + }} + data = client.download(df.order_type, params=params) + ebics_files += self._handle_download_data(data, df) + success = True + except EbicsFunctionalError: + e = exc_info() + self.note += '\n' + self.note += _( + "EBICS Functional Error during download of File Format %s (%s):" + ) % (df.name, df.order_type) + self.note += '\n' + self.note += '%s (code: %s)' % (e[1].message, e[1].code) + except EbicsTechnicalError: + e = exc_info() + self.note += '\n' + self.note += _( + "EBICS Technical Error during download of File Format %s (%s):" + ) % (df.name, df.order_type) + self.note += '\n' + self.note += '%s (code: %s)' % (e[1].message, e[1].code) + except EbicsVerificationError: + self.note += '\n' + self.note += _( + "EBICS Verification Error during download of " + "File Format %s (%s):" + ) % (df.name, df.order_type) + self.note += '\n' + self.note += _("The EBICS response could not be verified.") + except UserError as e: + self.note += '\n' + self.note += _( + "Warning during download of File Format %s (%s):" + ) % (df.name, df.order_type) + self.note += '\n' + self.note += e.name + except Exception: + self.note += '\n' + self.note += _( + "Unknown Error during download of File Format %s (%s):" + ) % (df.name, df.order_type) + tb = ''.join(format_exception(*exc_info())) + self.note += '\n%s' % tb + else: + # mark received data so that it is not included in further + # downloads + trans_id = client.last_trans_id + client.confirm_download(trans_id=trans_id, success=success) + + ctx['ebics_file_ids'] = ebics_files._ids + + if ebics_files: + self.note += '\n' + for f in ebics_files: + self.note += _( + "EBICS File '%s' is available for further processing." + ) % f.name + self.note += '\n' + + module = __name__.split('addons.')[1].split('.')[0] + result_view = self.env.ref( + '%s.ebics_xfer_view_form_result' % module) + return { + 'name': _('EBICS file transfer result'), + 'res_id': self.id, + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'ebics.xfer', + 'view_id': result_view.id, + 'target': 'new', + 'context': ctx, + 'type': 'ir.actions.act_window', + } + + def button_close(self): + self.ensure_one() + return {'type': 'ir.actions.act_window_close'} + + def view_ebics_file(self): + self.ensure_one() + module = __name__.split('addons.')[1].split('.')[0] + act = self.env['ir.actions.act_window']._for_xml_id( + '{}.ebics_file_action_download'.format(module)) + act['domain'] = [('id', 'in', self._context['ebics_file_ids'])] + return act + + def _ebics_upload(self): + self.ensure_one() + ebics_file = self.env['ebics.file'] + self.note = '' + client = self._setup_client() + if client: + upload_data = base64.decodestring(self.upload_data) + ef_format = self.format_id + OrderID = False + try: + order_type = self.order_type + if order_type == 'FUL': + kwargs = {} + bank = self.ebics_config_id.journal_ids[0].bank_id + cc = bank.country.code + if cc: + kwargs['country'] = cc + if self.test_mode: + kwargs['TEST'] = 'TRUE' + OrderID = client.FUL(ef_format.name, upload_data, **kwargs) + else: + OrderID = client.upload(order_type, upload_data) + if OrderID: + self.note += '\n' + self.note += _( + "EBICS File has been uploaded (OrderID %s)." + ) % OrderID + ef_note = _("EBICS OrderID: %s") % OrderID + if self.env.context.get('origin'): + ef_note += '\n' + _( + "Origin: %s") % self._context['origin'] + suffix = self.format_id.suffix + fn = self.upload_fname + if not fn.endswith(suffix): + fn = '.'.join([fn, suffix]) + ef_vals = { + 'name': self.upload_fname, + 'data': self.upload_data, + 'date': fields.Datetime.now(), + 'format_id': self.format_id.id, + 'state': 'done', + 'user_id': self._uid, + 'ebics_userid_id': self.ebics_userid_id.id, + 'note': ef_note, + "company_ids": [ + self.env.context.get("force_company", self.env.company.id) + ], + } + self._update_ef_vals(ef_vals) + ebics_file = self.env['ebics.file'].create(ef_vals) + + except EbicsFunctionalError: + e = exc_info() + self.note += '\n' + self.note += _("EBICS Functional Error:") + self.note += '\n' + self.note += '%s (code: %s)' % (e[1].message, e[1].code) + except EbicsTechnicalError: + e = exc_info() + self.note += '\n' + self.note += _("EBICS Technical Error:") + self.note += '\n' + self.note += '%s (code: %s)' % (e[1].message, e[1].code) + except EbicsVerificationError: + self.note += '\n' + self.note += _("EBICS Verification Error:") + self.note += '\n' + self.note += _("The EBICS response could not be verified.") + except Exception: + self.note += '\n' + self.note += _("Unknown Error") + tb = ''.join(format_exception(*exc_info())) + self.note += '\n%s' % tb + + if self.ebics_config_id.ebics_version == 'H003' and OrderID: + self.ebics_config_id._update_order_number(OrderID) + + return ebics_file + + def _setup_client(self): + self.ebics_config_id._check_ebics_keys() + passphrase = self._get_passphrase() + keyring = EbicsKeyRing( + keys=self.ebics_userid_id.ebics_keys_fn, + passphrase=passphrase) + + bank = EbicsBank( + keyring=keyring, + hostid=self.ebics_config_id.ebics_host, + url=self.ebics_config_id.ebics_url) + if self.ebics_config_id.ebics_version == 'H003': + bank._order_number = self.ebics_config_id._get_order_number() + + user = EbicsUser( + keyring=keyring, + partnerid=self.ebics_config_id.ebics_partner, + userid=self.ebics_userid_id.name) + signature_class = self.format_id.signature_class \ + or self.ebics_userid_id.signature_class + if signature_class == 'T': + user.manual_approval = True + + try: + client = EbicsClient( + bank, user, version=self.ebics_config_id.ebics_version) + except Exception: + self.note += '\n' + self.note += _("Unknown Error") + tb = ''.join(format_exception(*exc_info())) + self.note += '\n%s' % tb + client = False + + return client + + def _get_passphrase(self): + passphrase = self.ebics_userid_id.ebics_passphrase + + if passphrase: + return passphrase + + module = __name__.split('addons.')[1].split('.')[0] + passphrase_view = self.env.ref( + '%s.ebics_xfer_view_form_passphrase' % module) + return { + 'name': _('EBICS file transfer'), + 'res_id': self.id, + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'ebics.xfer', + 'view_id': passphrase_view.id, + 'target': 'new', + 'context': self._context, + 'type': 'ir.actions.act_window', + } + + def _file_format_methods(self): + """ + Extend this dictionary in order to add support + for extra file formats. + """ + res = { + 'camt.xxx.cfonb120.stm': self._handle_cfonb120, + 'camt.052.001.02.stm': self._handle_camt052, + 'camt.053.001.02.stm': self._handle_camt053, + } + return res + + def _update_ef_vals(self, ef_vals): + """ + Adapt this method to customize the EBICS File values. + """ + if self.format_id and self.format_id.type == 'up': + fn = ef_vals['name'] + dups = self._check_duplicate_ebics_file( + fn, self.format_id) + if dups: + n = 1 + fn = '_'.join([fn, str(n)]) + while self._check_duplicate_ebics_file(fn, self.format_id): + n += 1 + fn = '_'.join([fn, str(n)]) + ef_vals['name'] = fn + + def _handle_download_data(self, data, file_format): + ebics_files = self.env['ebics.file'] + if isinstance(data, dict): + for doc in data: + ebics_files += self._create_ebics_file( + data[doc], file_format, docname=doc) + else: + ebics_files += self._create_ebics_file(data, file_format) + return ebics_files + + def _create_ebics_file(self, data, file_format, docname=None): + """ + Write the data as received over the EBICS connection + to a temporary file so that is is available for + analysis (e.g. in case formats are received that cannot + be handled in the current version of this module). + + TODO: add code to clean-up /tmp on a regular basis. + + After saving the data received we call the method to perform + file format specific processing. + """ + ebics_files_root = self.ebics_config_id.ebics_files + tmp_dir = os.path.normpath(ebics_files_root + '/tmp') + if not os.path.isdir(tmp_dir): + os.makedirs(tmp_dir, mode=0o700) + fn_parts = [self.ebics_config_id.ebics_host, + self.ebics_config_id.ebics_partner] + if docname: + fn_parts.append(docname) + else: + fn_date = self.date_to or fields.Date.today() + fn_parts.append(fn_date.isoformat()) + base_fn = '_'.join(fn_parts) + n = 1 + full_tmp_fn = os.path.normpath(tmp_dir + '/' + base_fn) + while os.path.exists(full_tmp_fn): + n += 1 + tmp_fn = base_fn + '_' + str(n).rjust(3, '0') + full_tmp_fn = os.path.normpath(tmp_dir + '/' + tmp_fn) + + with open(full_tmp_fn, 'wb') as f: + f.write(data) + + ff_methods = self._file_format_methods() + if file_format.name in ff_methods: + data = ff_methods[file_format.name](data) + + fn = '.'.join([base_fn, file_format.suffix]) + dups = self._check_duplicate_ebics_file(fn, file_format) + if dups: + raise UserError(_( + "EBICS File with name '%s' has already been downloaded." + "\nPlease check this file and rename in case there is " + "no risk on duplicate transactions.") + % fn) + data = base64.encodebytes(data) + ef_vals = { + 'name': fn, + 'data': data, + 'date': fields.Datetime.now(), + 'date_from': self.date_from, + 'date_to': self.date_to, + 'format_id': file_format.id, + 'user_id': self._uid, + 'ebics_userid_id': self.ebics_userid_id.id, + 'company_ids': self.ebics_config_id.company_ids.ids, + } + self._update_ef_vals(ef_vals) + ebics_file = self.env['ebics.file'].create(ef_vals) + return ebics_file + + def _check_duplicate_ebics_file(self, fn, file_format): + dups = self.env['ebics.file'].search( + [('name', '=', fn), + ('format_id', '=', file_format.id)]) + return dups + + def _detect_upload_format(self): + """ + Use this method in order to automatically detect and set the + EBICS upload file format. + """ + pass + + def _update_order_number(self, OrderID): + o_list = list(OrderID) + for i, c in enumerate(reversed(o_list), start=1): + if c == '9': + o_list[-i] = 'A' + break + if c == 'Z': + continue + else: + o_list[-i] = chr(ord(c) + 1) + break + next_nr = ''.join(o_list) + if next_nr == 'ZZZZ': + next_nr = 'A000' + self.ebics_config_id.order_number = next_nr + + def _insert_line_terminator(self, data_in, line_len): + data_in = data_in.replace(b'\n', b'').replace(b'\r', b'') + data_out = b'' + max_len = len(data_in) + i = 0 + while i + line_len <= max_len: + data_out += data_in[i:i + line_len] + b'\n' + i += line_len + return data_out + + def _handle_cfonb120(self, data_in): + return self._insert_line_terminator(data_in, 120) + + def _handle_cfonb240(self, data_in): + return self._insert_line_terminator(data_in, 240) + + def _handle_camt052(self, data_in): + """ + Use this method if you need to fix camt files received + from your bank before passing them to the + Odoo Community CAMT parser. + Remark: Odoo Enterprise doesn't support camt.052. + """ + return data_in + + def _handle_camt053(self, data_in): + """ + Use this method if you need to fix camt files received + from your bank before passing them to the + Odoo Enterprise or Community CAMT parser. + """ + return data_in diff --git a/account_ebics/wizards/ebics_xfer.xml b/account_ebics/wizards/ebics_xfer.xml new file mode 100644 index 0000000..091c08b --- /dev/null +++ b/account_ebics/wizards/ebics_xfer.xml @@ -0,0 +1,102 @@ + + + + + EBICS File Download + ebics.xfer + 1 + +
+ + + + + + + + + + +
+
+
+
+
+ + + EBICS File Upload + ebics.xfer + 1 + +
+ + + + + + + + + + + + + +
+
+
+
+
+ + + EBICS File Transfer + ebics.xfer + 2 + +
+ + +
+
+ +
+
+ + + EBICS File Transfer + ir.actions.act_window + ebics.xfer + form + new + {'ebics_download': 1} + + + + + EBICS File Transfer + ir.actions.act_window + ebics.xfer + form + new + {'ebics_upload': 1} + + + +
diff --git a/account_ebics_oca_statement_import/README.rst b/account_ebics_oca_statement_import/README.rst new file mode 100644 index 0000000..4fe75a6 --- /dev/null +++ b/account_ebics_oca_statement_import/README.rst @@ -0,0 +1,17 @@ +.. image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: https://www.gnu.org/licenses/lgpl + :alt: License: LGPL-3 + +========================================================== +Deploy account_ebics module with OCA Bank Statement Import +========================================================== + +This module makes it possible to use OCA account_statement_import +in combination with 'account_ebics'. + +This module will be installed automatically when following modules are activated +on your odoo database : + +- account_ebics +- account_statement_import + diff --git a/account_ebics_oca_statement_import/__init__.py b/account_ebics_oca_statement_import/__init__.py new file mode 100644 index 0000000..5cb1c49 --- /dev/null +++ b/account_ebics_oca_statement_import/__init__.py @@ -0,0 +1 @@ +from . import wizards diff --git a/account_ebics_oca_statement_import/__manifest__.py b/account_ebics_oca_statement_import/__manifest__.py new file mode 100644 index 0000000..d9f3fef --- /dev/null +++ b/account_ebics_oca_statement_import/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2020 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +{ + 'name': 'account_ebics with OCA Bank Statement Imoort', + 'summary': "Use OCA Bank Statement Import with account_ebics", + 'version': '14.0.1.0.0', + 'author': 'Noviat', + 'category': 'Hidden', + 'license': 'LGPL-3', + 'depends': [ + 'account_ebics', + 'account_statement_import', + ], + 'installable': True, + 'auto_install': True, +} diff --git a/account_ebics_oca_statement_import/static/description/icon.png b/account_ebics_oca_statement_import/static/description/icon.png new file mode 100644 index 0000000..889d129 Binary files /dev/null and b/account_ebics_oca_statement_import/static/description/icon.png differ diff --git a/account_ebics_oca_statement_import/wizards/__init__.py b/account_ebics_oca_statement_import/wizards/__init__.py new file mode 100644 index 0000000..ae69bca --- /dev/null +++ b/account_ebics_oca_statement_import/wizards/__init__.py @@ -0,0 +1 @@ +from . import account_statement_import diff --git a/account_ebics_oca_statement_import/wizards/account_statement_import.py b/account_ebics_oca_statement_import/wizards/account_statement_import.py new file mode 100644 index 0000000..235c796 --- /dev/null +++ b/account_ebics_oca_statement_import/wizards/account_statement_import.py @@ -0,0 +1,62 @@ +# Copyright 2009-2020 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +import logging + +from odoo import models, _ + +_logger = logging.getLogger(__name__) + + +class AccountStatementImport(models.TransientModel): + _inherit = 'account.statement.import' + + def _check_parsed_data(self, stmts_vals): + """ Basic and structural verifications """ + if self.env.context.get('active_model') == 'ebics.file': + message = False + if len(stmts_vals) == 0: + message = _("This file doesn't contain any statement.") + if not message: + no_st_line = True + for vals in stmts_vals: + if vals['transactions'] and len(vals['transactions']) > 0: + no_st_line = False + break + if no_st_line: + message = _('This file doesn\'t contain any transaction.') + if message: + log_msg = _( + "Error detected while processing and EBICS File" + ) + ':\n' + message + _logger.warn(log_msg) + return + super()._check_parsed_data(stmts_vals) + + def _create_bank_statements(self, stmts_vals, result): + """ + Return error message to ebics.file when handling empty camt. + + Remarks/TODO: + We could add more info to the message (e.g. date, balance, ...) + and write this to the ebics.file, note field. + We could also create empty bank statement (in state done) to clearly + show days without transactions via the bank statement list view. + """ + if self.env.context.get('active_model') == 'ebics.file': + transactions = False + for st_vals in stmts_vals: + if st_vals.get('transactions'): + transactions = True + break + if not transactions: + message = _('This file doesn\'t contain any transaction.') + st_line_ids = [] + notifications = { + 'type': 'warning', + 'message': message, + 'details': '' + } + return st_line_ids, [notifications] + + return super()._create_bank_statements(stmts_vals, result) diff --git a/account_ebics_oe/README.rst b/account_ebics_oe/README.rst new file mode 100644 index 0000000..cf11b5f --- /dev/null +++ b/account_ebics_oe/README.rst @@ -0,0 +1,16 @@ +.. image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: https://www.gnu.org/licenses/lgpl + :alt: License: LGPL-3 + +============================================== +Deploy account_ebics module on Odoo Enterprise +============================================== + +This module makes it possible to deploy the 'account_ebics' +module on Odoo Enterprise. + +This module will be installed automatically when following modules are activated +on your odoo database : + +- account_ebics +- account_accountant diff --git a/account_ebics_oe/__init__.py b/account_ebics_oe/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/account_ebics_oe/__manifest__.py b/account_ebics_oe/__manifest__.py new file mode 100644 index 0000000..cdaa6fe --- /dev/null +++ b/account_ebics_oe/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2020 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +{ + 'name': 'account_ebics on Odoo Enterprise', + 'summary': "Deploy account_ebics module on Odoo Enterprise", + 'version': '14.0.1.0.0', + 'author': 'Noviat', + 'category': 'Hidden', + 'license': 'LGPL-3', + 'depends': [ + 'account_ebics', + 'account_accountant', + ], + 'data': [ + 'views/account_ebics_menu.xml' + ], + 'installable': True, + 'auto_install': True, +} diff --git a/account_ebics_oe/static/description/icon.png b/account_ebics_oe/static/description/icon.png new file mode 100644 index 0000000..889d129 Binary files /dev/null and b/account_ebics_oe/static/description/icon.png differ diff --git a/account_ebics_oe/views/account_ebics_menu.xml b/account_ebics_oe/views/account_ebics_menu.xml new file mode 100644 index 0000000..0e68143 --- /dev/null +++ b/account_ebics_oe/views/account_ebics_menu.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/account_ebics_oe_statement_import/README.rst b/account_ebics_oe_statement_import/README.rst new file mode 100644 index 0000000..322501f --- /dev/null +++ b/account_ebics_oe_statement_import/README.rst @@ -0,0 +1,17 @@ +.. image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: https://www.gnu.org/licenses/lgpl + :alt: License: LGPL-3 + +====================================================================== +Deploy account_ebics module with Odoo Enterprise Bank Statement Import +====================================================================== + +This module makes it possible to use Odoo Enterprise account_bank_statement_import +in combination with 'account_ebics'. + +This module will be installed automatically when following modules are activated +on your odoo database : + +- account_ebics_oe +- account_bank_statement_import + diff --git a/account_ebics_oe_statement_import/__init__.py b/account_ebics_oe_statement_import/__init__.py new file mode 100644 index 0000000..5cb1c49 --- /dev/null +++ b/account_ebics_oe_statement_import/__init__.py @@ -0,0 +1 @@ +from . import wizards diff --git a/account_ebics_oe_statement_import/__manifest__.py b/account_ebics_oe_statement_import/__manifest__.py new file mode 100644 index 0000000..794f45a --- /dev/null +++ b/account_ebics_oe_statement_import/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2020 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +{ + 'name': 'account_ebics with Odoo Enterprise Bank Statement Import', + 'summary': "Use Odoo Enterprise Bank Statement Import with account_ebics", + 'version': '14.0.1.0.0', + 'author': 'Noviat', + 'category': 'Hidden', + 'license': 'LGPL-3', + 'depends': [ + 'account_ebics_oe', + 'account_bank_statement_import', + ], + 'installable': True, + 'auto_install': True, +} diff --git a/account_ebics_oe_statement_import/static/description/icon.png b/account_ebics_oe_statement_import/static/description/icon.png new file mode 100644 index 0000000..889d129 Binary files /dev/null and b/account_ebics_oe_statement_import/static/description/icon.png differ diff --git a/account_ebics_oe_statement_import/wizards/__init__.py b/account_ebics_oe_statement_import/wizards/__init__.py new file mode 100644 index 0000000..7dafcd1 --- /dev/null +++ b/account_ebics_oe_statement_import/wizards/__init__.py @@ -0,0 +1 @@ +from . import account_bank_statement_import diff --git a/account_ebics_oe_statement_import/wizards/account_bank_statement_import.py b/account_ebics_oe_statement_import/wizards/account_bank_statement_import.py new file mode 100644 index 0000000..937f807 --- /dev/null +++ b/account_ebics_oe_statement_import/wizards/account_bank_statement_import.py @@ -0,0 +1,62 @@ +# Copyright 2009-2020 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +import logging + +from odoo import models, _ + +_logger = logging.getLogger(__name__) + + +class AccountBankStatementImport(models.TransientModel): + _inherit = 'account.bank.statement.import' + + def _check_parsed_data(self, stmts_vals, account_number): + """ Basic and structural verifications """ + if self.env.context.get('active_model') == 'ebics.file': + message = False + if len(stmts_vals) == 0: + message = _("This file doesn't contain any statement.") + if not message: + no_st_line = True + for vals in stmts_vals: + if vals['transactions'] and len(vals['transactions']) > 0: + no_st_line = False + break + if no_st_line: + message = _('This file doesn\'t contain any transaction.') + if message: + log_msg = _( + "Error detected while processing and EBICS File" + ) + ':\n' + message + _logger.warn(log_msg) + return + super()._check_parsed_data(stmts_vals, account_number) + + def _create_bank_statements(self, stmts_vals): + """ + Return error message to ebics.file when handling empty camt. + + Remarks/TODO: + We could add more info to the message (e.g. date, balance, ...) + and write this to the ebics.file, note field. + We could also create empty bank statement (in state done) to clearly + show days without transactions via the bank statement list view. + """ + if self.env.context.get('active_model') == 'ebics.file': + transactions = False + for st_vals in stmts_vals: + if st_vals.get('transactions'): + transactions = True + break + if not transactions: + message = _('This file doesn\'t contain any transaction.') + st_line_ids = [] + notifications = { + 'type': 'warning', + 'message': message, + 'details': '' + } + return st_line_ids, [notifications] + + return super()._create_bank_statements(stmts_vals)