diff --git a/.copier-answers.yml b/.copier-answers.yml index 8e5ce91..476efe8 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -14,10 +14,10 @@ odoo_version: 16.0 org_name: Noviat org_slug: Noviat rebel_module_groups: [] -repo_description: 'Noviat Generic - Accounting Repository' -repo_name: accounting -repo_slug: accounting -repo_website: https://picasso.noviat.com/Noviat/Noviat_Generic/accounting/ +repo_description: 'EBICS support for Odoo' +repo_name: account_ebics +repo_slug: account_ebics +repo_website: https://www.noviat.com travis_apt_packages: [] travis_apt_sources: [] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 547afba..4904dc9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,8 @@ exclude: | (?x) # NOT INSTALLABLE ADDONS - ^server_environment_files/| + ^account_ebics_oca_statement_import/| + ^account_ebics_payment_order/| # END NOT INSTALLABLE ADDONS # Files and folders generated by bots, to avoid loops ^setup/|/static/description/index\.html$| @@ -19,7 +20,7 @@ exclude: | /TODO/ default_language_version: python: python3 - node: "16.10.0" + node: "16.17.0" repos: - repo: local hooks: @@ -36,7 +37,7 @@ repos: # update the NOT INSTALLABLE ADDONS section above - id: oca-update-pre-commit-excluded-addons - id: oca-fix-manifest-website - args: ["https://www.noviat.com/"] + args: ["https://www.noviat.com"] - repo: https://github.com/myint/autoflake rev: v1.7.7 hooks: @@ -118,7 +119,7 @@ repos: name: flake8 additional_dependencies: ["flake8-bugbear==20.1.4"] - repo: https://github.com/OCA/pylint-odoo - rev: v8.0.13 + rev: 7.0.2 hooks: - id: pylint_odoo name: pylint with optional checks diff --git a/.pylintrc b/.pylintrc index 0dfb7a7..6e9f8a2 100644 --- a/.pylintrc +++ b/.pylintrc @@ -67,6 +67,39 @@ enable=anomalous-backslash-in-string, use-vim-comment, wrong-tabs-instead-of-spaces, xml-syntax-error, + attribute-string-redundant, + character-not-valid-in-resource-link, + consider-merging-classes-inherited, + context-overridden, + create-user-wo-reset-password, + dangerous-filter-wo-user, + dangerous-qweb-replace-wo-priority, + deprecated-data-xml-node, + deprecated-openerp-xml-node, + duplicate-po-message-definition, + except-pass, + file-not-used, + invalid-commit, + manifest-maintainers-list, + missing-newline-extrafiles, + missing-readme, + missing-return, + odoo-addons-relative-import, + old-api7-method-defined, + po-msgstr-variables, + po-syntax-error, + renamed-field-parameter, + resource-not-exist, + str-format-used, + test-folder-imported, + translation-contains-variable, + translation-positional-used, + unnecessary-utf8-coding-comment, + website-manifest-key-not-valid-uri, + xml-attribute-translatable, + xml-deprecated-qweb-directive, + xml-deprecated-tree-attribute, + external-request-timeout, # messages that do not cause the lint step to fail consider-merging-classes-inherited, create-user-wo-reset-password, diff --git a/.pylintrc-mandatory b/.pylintrc-mandatory index f3c1f96..880a88f 100644 --- a/.pylintrc-mandatory +++ b/.pylintrc-mandatory @@ -41,9 +41,8 @@ enable=anomalous-backslash-in-string, method-inverse, method-required-super, method-search, - missing-import-error, - missing-manifest-dependency, openerp-exception-warning, + pointless-statement, pointless-string-statement, print-used, redundant-keyword-arg, @@ -59,7 +58,40 @@ enable=anomalous-backslash-in-string, unreachable, use-vim-comment, wrong-tabs-instead-of-spaces, - xml-syntax-error + xml-syntax-error, + attribute-string-redundant, + character-not-valid-in-resource-link, + consider-merging-classes-inherited, + context-overridden, + create-user-wo-reset-password, + dangerous-filter-wo-user, + dangerous-qweb-replace-wo-priority, + deprecated-data-xml-node, + deprecated-openerp-xml-node, + duplicate-po-message-definition, + except-pass, + file-not-used, + invalid-commit, + manifest-maintainers-list, + missing-newline-extrafiles, + missing-readme, + missing-return, + odoo-addons-relative-import, + old-api7-method-defined, + po-msgstr-variables, + po-syntax-error, + renamed-field-parameter, + resource-not-exist, + str-format-used, + test-folder-imported, + translation-contains-variable, + translation-positional-used, + unnecessary-utf8-coding-comment, + website-manifest-key-not-valid-uri, + xml-attribute-translatable, + xml-deprecated-qweb-directive, + xml-deprecated-tree-attribute, + external-request-timeout [REPORTS] msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg} diff --git a/account_ebics/README.rst b/account_ebics/README.rst new file mode 100644 index 0000000..dd9e9d7 --- /dev/null +++ b/account_ebics/README.rst @@ -0,0 +1,218 @@ +.. 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 for EBICS 2.x +and fintech 7.2.7 or higher for EBICS 3.0. + +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 + + Cf. https://github.com/Noviat/account_ebics + +| + +- account_ebics_batch + + This module adds a cron job for the automated import of EBICS files. + + Cf. https://github.com/Noviat/account_ebics + +| + +- account_ebics_batch_payment + + Recommended if you are using the Odoo Enterprise account_batch_payment module + + Cf. https://github.com/Noviat/account_ebics + +| + +- account_usability + + Recommended if you have multiple financial journals. + This module adds a number of accounting menu entries such as bank statement list view + which allows to see all statements downloaded via the ir.cron automated EBICS download. + + Cf. https://github.com/OCA/account-financial-tools + +| + +- account_ebics_payment_order + + Recommended if you are using the OCA account_payment_order module. + + Cf. https://github.com/Noviat/account_ebics and 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 to import externally generated keys & certificates (currently only 3SKey signature certificate) +- For Odoo 16.0 the interaction with the OCA payment order and bank statement import modules (e.g. french CFONB) is not yet available. + 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..a146884 --- /dev/null +++ b/account_ebics/__manifest__.py @@ -0,0 +1,32 @@ +# Copyright 2009-2023 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +{ + "name": "EBICS banking protocol", + "version": "16.0.1.0.0", + "license": "LGPL-3", + "author": "Noviat", + "website": "https://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..05bad93 --- /dev/null +++ b/account_ebics/data/ebics_file_format.xml @@ -0,0 +1,215 @@ + + + + + + + 2 + camt.052 + down + C52 + camt.052 + bank to customer account report in format camt.052 + c52.xml + + + + 2 + camt.052 + down + Z52 + camt.052 + bank to customer account report in format camt.052 + c52.xml + + + + 2 + camt.053 + down + C53 + camt.053 + Bank to customer statement report in format camt.053 + c53.xml + + + + 2 + camt.053 + down + Z53 + camt.053 + Bank to customer statement report in format camt.053 + c53.xml + + + + 2 + camt.054 + down + C54 + camt.054 + Bank to customer debit credit notification in format camt.054 + c52.xml + + + + 2 + camt.054 + down + Z54 + camt.054 + Bank to customer debit credit notification in format camt.054 + c52.xml + + + + 2 + camt.xxx.cfonb120.stm + down + FDL + cfonb120 + Bank to customer statement report in format cfonb120 + cfonb120.dat + + + + 2 + pain.002 + down + CDZ + Payment status report for direct debit in format pain.002 + psr.xml + + + + 2 + pain.002 + down + Z01 + pain.002 + Payment status report for direct debit in format pain.002 + psr.xml + + + + 3 + down + BTD + cfonb120 + Bank to customer statement report in format cfonb120 + cfonb120.dat + EOP + cfonb120 + + + + + + 2 + pain.xxx.cfonb160.dco + up + FUL + Remises de LCR + txt + + + + 2 + pain.001.001.03 + up + CCT + Payment Order in format pain.001.001.03 + xml + + + + 2 + pain.001.001.03 + up + XE2 + Payment Order in format pain.001.001.03 + xml + + + + 2 + pain.008.001.02.sdd + up + CDD + Sepa Core Direct Debit Order in format pain.008.001.02 + xml + + + + 2 + pain.008.001.02.sdd + up + XE3 + Sepa Core Direct Debit Order in format pain.008.001.02 + xml + + + + 2 + pain.008.001.02.sbb + up + CDB + Sepa Direct Debit (B2B) Order in format pain.008.001.02 + xml + + + + 2 + pain.008.001.02.sbb + up + XE4 + Sepa Direct Debit (B2B) Order in format pain.008.001.02 + xml + + + + 2 + pain.001.001.02.sct + up + FUL + Payment Order in format pain.001.001.02 + xml + + + + 3 + up + BTU + SEPA credit transfer + txt + SCT + pain.001 + GLB + + + diff --git a/account_ebics/doc/2017-03-29-EBICS_V_3.0-FinalVersion.pdf b/account_ebics/doc/2017-03-29-EBICS_V_3.0-FinalVersion.pdf new file mode 100644 index 0000000..472c58f Binary files /dev/null and b/account_ebics/doc/2017-03-29-EBICS_V_3.0-FinalVersion.pdf differ diff --git a/account_ebics/doc/2017-03-29-EBICS_V_3.0_Annex1_ReturnCodes-FinalVersion.pdf b/account_ebics/doc/2017-03-29-EBICS_V_3.0_Annex1_ReturnCodes-FinalVersion.pdf new file mode 100644 index 0000000..1df4109 Binary files /dev/null and b/account_ebics/doc/2017-03-29-EBICS_V_3.0_Annex1_ReturnCodes-FinalVersion.pdf differ 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..0c91541 --- /dev/null +++ b/account_ebics/migrations/13.0.1.1/noupdate_changes.xml @@ -0,0 +1,22 @@ + + + + + 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..c1ef5d4 --- /dev/null +++ b/account_ebics/migrations/13.0.1.1/post-migration.py @@ -0,0 +1,83 @@ +# Copyright 2009-2020 Noviat. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import os + +from openupgradelib import openupgrade # pylint: disable=W7936 + + +@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.ids)], + "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..6c0dd75 --- /dev/null +++ b/account_ebics/migrations/13.0.1.3/post-migration.py @@ -0,0 +1,44 @@ +# 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( # pylint: disable=E8103 + """ + 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( # pylint: disable=E8103 + """ + 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..04212cd --- /dev/null +++ b/account_ebics/migrations/13.0.1.3/pre-migration.py @@ -0,0 +1,81 @@ +# 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( # pylint: disable=E8103 + """ + 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) # pylint: disable=E8103 diff --git a/account_ebics/migrations/15.0.1.1/pre-migration.py b/account_ebics/migrations/15.0.1.1/pre-migration.py new file mode 100644 index 0000000..28ac779 --- /dev/null +++ b/account_ebics/migrations/15.0.1.1/pre-migration.py @@ -0,0 +1,54 @@ +# Copyright 2009-2022 Noviat. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +def migrate(cr, version): + if not version: + return + + cr.execute("select id from ebics_config") + cfg_ids = [x[0] for x in cr.fetchall()] + for cfg_id in cfg_ids: + cr.execute( + """ + SELECT DISTINCT aj.company_id + FROM account_journal_ebics_config_rel rel + JOIN account_journal aj ON rel.account_journal_id = aj.id + WHERE ebics_config_id = %s + """, + (cfg_id,), + ) + new_cpy_ids = [x[0] for x in cr.fetchall()] + cr.execute( + """ + SELECT DISTINCT res_company_id + FROM ebics_config_res_company_rel + WHERE ebics_config_id = %s + """, + (cfg_id,), + ) + old_cpy_ids = [x[0] for x in cr.fetchall()] + + to_add = [] + for cid in new_cpy_ids: + if cid in old_cpy_ids: + old_cpy_ids.remove(cid) + else: + to_add.append(cid) + if old_cpy_ids: + cr.execute( + """ + DELETE FROM ebics_config_res_company_rel + WHERE res_company_id IN %s + """, + (tuple(old_cpy_ids),), + ) + if to_add: + for cid in to_add: + cr.execute( + """ + INSERT INTO ebics_config_res_company_rel(ebics_config_id, res_company_id) + VALUES (%s, %s); + """, + (cfg_id, cid), + ) 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..e5eb289 --- /dev/null +++ b/account_ebics/models/account_bank_statement.py @@ -0,0 +1,10 @@ +# Copyright 2009-2023 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..1726ae2 --- /dev/null +++ b/account_ebics/models/ebics_config.py @@ -0,0 +1,265 @@ +# Copyright 2009-2023 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +import logging +import os +import re + +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( + readonly=True, + states={"draft": [("readonly", False)]}, + required=True, + ) + journal_ids = fields.Many2many( + comodel_name="account.journal", + relation="account_journal_ebics_config_rel", + readonly=True, + states={"draft": [("readonly", False)]}, + string="Bank Accounts", + domain="[('type', '=', 'bank')]", + ) + 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)"), + ("H005", "H005 (3.0)"), + ], + 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")], + 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(default=True) + company_ids = fields.Many2many( + comodel_name="res.company", + relation="ebics_config_res_company_rel", + string="Companies", + readonly=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("ebics_key_bitlength") + def _check_ebics_key_bitlength(self): + for cfg in self: + if cfg.ebics_version == "H005" and cfg.ebics_key_bitlength < 2048: + raise UserError(_("EBICS key bitlength must be >= 2048.")) + + @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}" + ) + ) + + def write(self, vals): + """ + Due to the multi-company nature of the EBICS config we + need to adapt the company_ids in the write method. + """ + if "journal_ids" not in vals: + return super().write(vals) + for rec in self: + old_company_ids = rec.journal_ids.mapped("company_id").ids + super(EbicsConfig, rec).write(vals) + new_company_ids = rec.journal_ids.mapped("company_id").ids + updates = [] + for cid in new_company_ids: + if cid in old_company_ids: + old_company_ids.remove(cid) + else: + updates += [(4, cid)] + updates += [(3, x) for x in old_company_ids] + super(EbicsConfig, rec).write({"company_ids": updates}) + return True + + def unlink(self): + for ebics_config in self: + if ebics_config.state == "active": + raise UserError(_("You cannot remove active EBICS configurations.")) + return super().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": + o_list[-i] = "0" + continue + else: + o_list[-i] = chr(ord(c) + 1) + break + next_order_number = "".join(o_list) + if next_order_number == "ZZZZ": + next_order_number = "A000" + self.order_number = next_order_number + + 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..7f960a3 --- /dev/null +++ b/account_ebics/models/ebics_file.py @@ -0,0 +1,625 @@ +# Copyright 2009-2023 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +import base64 +import logging +from copy import deepcopy +from sys import exc_info +from traceback import format_exception + +from lxml import etree + +from odoo import _, fields, models +from odoo.exceptions import UserError +from odoo.tools.safe_eval import safe_eval + +from odoo.addons.base.models.res_bank import sanitize_account_number + +_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")], + 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", + readonly=True, + ) + company_ids = fields.Many2many( + comodel_name="res.company", + string="Companies", + readonly=True, + 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().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() + self = self.with_context(allowed_company_ids=self.env.user.company_ids.ids) + 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.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 = [("id", "in", self.env.context.get("statement_ids"))] + action["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 '%(ebics_format)s' format is not installed " + "on your system. " + "\nPlease install module '%(module)s'", + ebics_format=self.format_id.name, + module=module, + ) + ) + return False + return True + + def _process_download_result(self, res): + statement_ids = res["statement_ids"] + notifications = res["notifications"] + sts_data = [] + if statement_ids: + self.env.flush_all() + self.env.cr.execute( + """ + SELECT abs.name, abs.date, abs.company_id, rc.name AS company_name + FROM account_bank_statement abs + JOIN res_company rc ON rc.id = abs.company_id + WHERE abs.id in %s + ORDER BY abs.date, rc.id + """, + (tuple(res["statement_ids"]),), + ) + sts_data = self.env.cr.dictfetchall() + + st_cnt = len(statement_ids) + warning_cnt = error_cnt = 0 + if notifications: + for notif in notifications: + if notif["type"] == "error": + error_cnt += 1 + elif notif["type"] == "warning": + warning_cnt += 1 + parts = [notif[k] for k in notif if k in ("message", "details")] + self.note_process += "\n".join(parts) + self.note_process += "\n\n" + self.note_process += "\n" + if error_cnt: + self.note_process += ( + _("Number of errors detected during import: %s: ") % error_cnt + ) + self.note_process += "\n" + if warning_cnt: + self.note_process += ( + _("Number of watnings detected during import: %s: ") % warning_cnt + ) + if st_cnt: + self.note_process += "\n\n" + self.note_process += _( + "%(st_cnt)s bank statement%(sp)s been imported: ", + st_cnt=st_cnt, + sp=st_cnt == 1 and _(" has") or _("s have"), + ) + 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"], + ) + if statement_ids: + self.sudo().bank_statement_ids = [(4, x) for x in statement_ids] + company_ids = self.sudo().bank_statement_ids.mapped("company_id").ids + self.company_ids = [(6, 0, company_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", + } + + def _process_cfonb120(self): + """ + Disable this code while waiting on OCA cfonb release for 16.0 + """ + # pylint: disable=W0101 + raise NotImplementedError + + 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_action = self.env["ir.actions.act_window"]._for_xml_id( + "account.action_bank_statement_tree" + ) + result_action["context"] = safe_eval(result_action["context"]) + statement_ids = [] + notifications = [] + for i, wiz_vals in enumerate(wiz_vals_list, start=1): + result = { + "statement_ids": [], + "notifications": [], + } + statement_filename = wiz_vals["statement_filename"] + wiz = ( + self.env[wiz_model] + .with_context(active_model="ebics.file") + .create(wiz_vals) + ) + try: + with self.env.cr.savepoint(): + file_data = base64.b64decode(wiz_vals["statement_file"]) + msg_hdr = _( + "{} : Import failed for statement number %(index)s, filename %(fn)s:\n", + index=i, + fn=statement_filename, + ) + wiz.import_single_file(file_data, result) + if not result["statement_ids"]: + message = msg_hdr.format(_("Warning")) + message += _( + "You have already imported this file, or this file " + "only contains already imported transactions." + ) + notifications += [ + { + "type": "warning", + "message": message, + } + ] + else: + statement_ids.extend(result["statement_ids"]) + notifications.extend(result["notifications"]) + + except UserError as e: + message = msg_hdr.format(_("Error")) + message += "".join(e.args) + notifications += [ + { + "type": "error", + "message": message, + } + ] + + except Exception: + tb = "".join(format_exception(*exc_info())) + message = msg_hdr.format(_("Error")) + message += tb + notifications += [ + { + "type": "error", + "message": message, + } + ] + + result_action["context"]["notifications"] = notifications + result_action["domain"] = [("id", "in", statement_ids)] + return self._process_result_action(result_action) + + def _unlink_cfonb120(self): + """ + Placeholder for cfonb120 specific actions before removing the + EBICS data file and its related bank statements. + """ + + def _process_camt052(self): + import_module = "account_statement_import_camt" + self._check_import_module(import_module) + return self._process_camt053(self) + + def _unlink_camt052(self): + """ + Placeholder for camt052 specific actions before removing the + EBICS data file and its related bank statements. + """ + + def _process_camt054(self): + import_module = "account_statement_import_camt" + self._check_import_module(import_module) + return self._process_camt053(self) + + def _unlink_camt054(self): + """ + Placeholder for camt054 specific actions before removing the + EBICS data file and its related bank statements. + """ + + def _process_camt053(self): # noqa C901 + """ + The Odoo standard statement import is based on manual selection + of a financial journal before importing the electronic statement file. + An EBICS download may return a single file containing a large number of + statements from different companies/journals. + Hence we need to split the CAMT file into + single statement CAMT files before we can call the logic + implemented by the Odoo OE or Community CAMT parsers. + + TODO: refactor method to enable removal of noqa C901 + """ + modules = [ + ("oca", "account_statement_import_camt"), + ("oe", "account_bank_statement_import_camt"), + ] + author = False + for entry in modules: + if self._check_import_module(entry[1], raise_if_not_found=False): + author = entry[0] + break + if not author: + raise UserError( + _( + "The module to process the '%(ebics_format)s' format is " + "not installed on your system. " + "\nPlease install one of the following modules: \n%(modules)s.", + ebics_format=self.format_id.name, + modules=", ".join([x[1] for x in modules]), + ) + ) + res = {"statement_ids": [], "notifications": []} + try: + with self.env.cr.savepoint(): + msg_hdr = _("{} : Import failed for file %(fn)s:\n", fn=self.name) + file_data = base64.b64decode(self.data) + root = etree.fromstring(file_data, parser=etree.XMLParser(recover=True)) + if root is None: + message = msg_hdr.format(_("Error")) + message += _("Invalid XML file.") + res["notifications"].append({"type": "error", "message": message}) + ns = {k or "ns": v for k, v in root.nsmap.items()} + for i, stmt in enumerate(root[0].findall("ns:Stmt", ns), start=1): + msg_hdr = _( + "{} : Import failed for statement number %(index)s, filename %(fn)s:\n", + index=i, + fn=self.name, + ) + acc_number = sanitize_account_number( + stmt.xpath( + "ns:Acct/ns:Id/ns:IBAN/text() | ns:Acct/ns:Id/ns:Othr/ns:Id/text()", + namespaces=ns, + )[0] + ) + if not acc_number: + message = msg_hdr.format(_("Error")) + message += _("No bank account number found.") + res["notifications"].append( + {"type": "error", "message": message} + ) + continue + currency_code = stmt.xpath( + "ns:Acct/ns:Ccy/text() | ns:Bal/ns:Amt/@Ccy", namespaces=ns + )[0] + currency = self.env["res.currency"].search( + [("name", "=ilike", currency_code)], limit=1 + ) + if not currency: + message = msg_hdr.format(_("Error")) + message += _("Currency %(cc) not found.", cc=currency_code) + res["notifications"] = {"type": "error", "message": message} + continue + journal = self.env["account.journal"].search( + [ + ("type", "=", "bank"), + ( + "bank_account_id.sanitized_acc_number", + "ilike", + acc_number, + ), + ] + ) + journal_currency = ( + journal.currency_id or journal.company_id.currency_id + ) + if journal_currency != currency: + message = msg_hdr.format(_("Error")) + message += _( + "No financial journal found for Account Number %(nbr)s, " + "Currency %(cc)", + nbr=acc_number, + cc=currency_code, + ) + res["notifications"].append( + {"type": "error", "message": message} + ) + continue + + root_new = deepcopy(root) + for j, el in enumerate(root_new[0].findall("ns:Stmt", ns), start=1): + if j != i: + el.getparent.remove(el) + data = base64.b64encode(etree.tostring(root_new)) + + if author == "oca": + # TODO: implement _process_camt053_oca() once OCA camt is + # released for 16.0 + raise NotImplementedError + else: + self.env.company = journal.company_id + attachment = self.env["ir.attachment"].create( + {"name": self.name, "datas": data, "store_fname": self.name} + ) + act = journal._import_bank_statement(attachment) + for entry in act["domain"]: + if ( + isinstance(entry, tuple) + and entry[0] == "statement_id" + and entry[1] == "in" + ): + res["statement_ids"].extend(entry[2]) + break + notifications = act["context"]["notifications"] + if notifications: + res["notifications"].append(act["context"]["notifications"]) + + except UserError as e: + message = msg_hdr.format(_("Error")) + message += "".join(e.args) + res["notifications"].append({"type": "error", "message": message}) + except Exception: + tb = "".join(format_exception(*exc_info())) + message = msg_hdr.format(_("Error")) + message += tb + res["notifications"].append({"type": "error", "message": message}) + + if author == "oca": + # TODO: implement _process_camt053_oca() once OCA camt is + # released for 16.0 + return self._process_camt053_oca() + else: + return self._process_download_result(res) + + def _process_camt053_oca(self): + """ + Disable this code while waiting on OCA CAMT parser for 16.0 + """ + # pylint: disable=W0101 + raise NotImplementedError + + wiz_model = "account.statement.import" + wiz_vals = { + "statement_filename": self.name, + "statement_file": self.data, + } + result_action = self.env["ir.actions.act_window"]._for_xml_id( + "account.action_bank_statement_tree" + ) + result_action["context"] = safe_eval(result_action["context"]) + result = { + "statement_ids": [], + "notifications": [], + } + statement_ids = [] + notifications = [] + wiz = ( + self.env[wiz_model].with_context(active_model="ebics.file").create(wiz_vals) + ) + msg_hdr = _( + "{} : Import failed for EBICS File %(fn)s:\n", + fn=wiz.statement_filename, + ) + try: + with self.env.cr.savepoint(): + file_data = base64.b64decode(self.data) + wiz.import_single_file(file_data, result) + + if not result["statement_ids"]: + message = msg_hdr.format(_("Warning")) + message += _( + "You have already imported this file, or this file " + "only contains already imported transactions." + ) + notifications += [ + { + "type": "warning", + "message": message, + } + ] + else: + statement_ids.extend(result["statement_ids"]) + notifications.extend(result["notifications"]) + + except UserError as e: + message = msg_hdr.format(_("Error")) + message += "".join(e.args) + notifications += [ + { + "type": "error", + "message": message, + } + ] + + except Exception: + tb = "".join(format_exception(*exc_info())) + message = msg_hdr.format(_("Error")) + message += tb + notifications += [ + { + "type": "error", + "message": message, + } + ] + + result_action["context"]["notifications"] = notifications + result_action["domain"] = [("id", "in", statement_ids)] + return self._process_result_action(result_action) + + def _unlink_camt053(self): + """ + Placeholder for camt053 specific actions before removing the + EBICS data file and its related bank statements. + """ + + def _process_pain002(self): + """ + Placeholder for processing pain.002 files. + TODO: + add import logic based upon OCA 'account_payment_return_import' + """ + + 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..a4f4081 --- /dev/null +++ b/account_ebics/models/ebics_file_format.py @@ -0,0 +1,118 @@ +# Copyright 2009-2023 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" + + ebics_version = fields.Selection( + selection=[ + ("2", "2"), + ("3", "3"), + ], + string="EBICS protocol version", + required=True, + default="2", + ) + name = fields.Char( + string="Request Type", + 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( + required=True, + help="EBICS 3.0: BTD (download) or BTU (upload).\n" + "EBICS 2.0: E.g. C53 (check your EBICS contract). " + "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")], + 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( + help="Specify the filename suffix for this File Format.\nE.g. c53.xml", + ) + # EBICS 3.0 BTF + btf_service = fields.Char( + string="BTF Service", + help="BTF Service Name)\n" + "The service code name consisting of 3 alphanumeric characters " + "[A-Z0-9] (e.g. SCT, SDD, STM, EOP)", + ) + btf_message = fields.Char( + string="BTF Message Name", + help="BTF Message Name\n" + "The message name consisting of up to 10 alphanumeric characters " + "[a-z0-9.] (eg. pain.001, pain.008, camt.053)", + ) + btf_scope = fields.Char( + string="BTF Scope", + help="Scope of service.\n" + "Either an ISO-3166 ALPHA 2 country code or an issuer code " + "of 3 alphanumeric characters [A-Z0-9].", + ) + btf_option = fields.Char( + string="BTF Option", + help="The service option code consisting of 3-10 alphanumeric " + "characters [A-Z0-9] (eg. COR, B2B)", + ) + btf_container = fields.Char( + string="BTF Container", + help="Type of container consisting of 3 characters [A-Z] (eg. XML, ZIP).", + ) + btf_version = fields.Char( + string="BTF Version", + help="Message version consisting of 2 numeric characters [0-9] (eg. 03).", + ) + btf_variant = fields.Char( + string="BTF Variant", + help="Message variant consisting of 3 numeric characters [0-9] (eg. 001).", + ) + btf_format = fields.Char( + string="BTF Format", + help="Message format consisting of 1-4 alphanumeric characters [A-Z0-9] " + "(eg. XML, JSON, PDF).", + ) + + @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 + + def name_get(self): + res = [] + for rec in self: + name = rec.ebics_version == "2" and rec.name or rec.btf_message + res.append((rec.id, name)) + return res diff --git a/account_ebics/models/ebics_userid.py b/account_ebics/models/ebics_userid.py new file mode 100644 index 0000000..6a863a9 --- /dev/null +++ b/account_ebics/models/ebics_userid.py @@ -0,0 +1,510 @@ +# Copyright 2009-2023 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 ( + EbicsBank, + EbicsClient, + EbicsFunctionalError, + EbicsKeyRing, + EbicsTechnicalError, + EbicsUser, + ) + + 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" + ) + ebics_version = fields.Selection(related="ebics_config_id.ebics_version") + 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")], + 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"), + ], + default="draft", + required=True, + readonly=True, + ) + active = fields.Boolean(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.replace(" ", "_") + "_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_key_x509") + def _check_ebics_key_x509(self): + for cfg in self: + if cfg.ebics_version == "H005" and not cfg.ebics_key_x509: + raise UserError(_("X.509 certificates must be used with EBICS 3.0.")) + + @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("ebics_version") + def _onchange_ebics_version(self): + if self.ebics_version == "H005": + self.ebics_key_x509 = True + + @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): # noqa: C901 + """ + 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 as err: + exctype, value = exc_info()[:2] + error = _("EBICS Initialisation Error:") + error += "\n" + str(exctype) + "\n" + str(value) + raise UserError(error) from err + + 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.decodebytes( + 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 as err: + exctype, value = exc_info()[:2] + error = _("EBICS Initialisation Error:") + error += "\n" + str(exctype) + "\n" + str(value) + raise UserError(error) from err + + 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) + + try: + client = EbicsClient(bank, user, version=ebics_version) + except RuntimeError as err: + e = exc_info() + error = _("EBICS Initialization Error:") + error += "\n" + error += err.args[0] + raise UserError(error) from err + + # 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 supported_versions and 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{}: {} ".format(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 as err: + 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 '%(url)s' - %(val)s", + url=self.ebics_config_id.ebics_url, + val=str(value), + ) + ) from err + except EbicsFunctionalError as err: + e = exc_info() + error = _("EBICS Functional Error:") + error += "\n" + error += "{} (code: {})".format(e[1].message, e[1].code) + raise UserError(error) from err + except EbicsTechnicalError as err: + e = exc_info() + error = _("EBICS Technical Error:") + error += "\n" + error += "{} (code: {})".format(e[1].message, e[1].code) + raise UserError(error) from err + + # 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 as err: + exctype, value = exc_info()[:2] + error = _("EBICS Initialisation Error:") + error += "\n" + str(exctype) + "\n" + str(value) + raise UserError(error) from err + + try: + public_bank_keys = client.HPB() + except EbicsFunctionalError as err: + e = exc_info() + error = _("EBICS Functional Error:") + error += "\n" + error += "{} (code: {})".format(e[1].message, e[1].code) + raise UserError(error) from err + except Exception as err: + exctype, value = exc_info()[:2] + error = _("EBICS Initialisation Error:") + error += "\n" + str(exctype) + "\n" + str(value) + raise UserError(error) from err + + 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.encodebytes(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..613e5be --- /dev/null +++ b/account_ebics/models/fintech_ebics_register.py @@ -0,0 +1,46 @@ +# 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..7b11eac --- /dev/null +++ b/account_ebics/security/ebics_security.xml @@ -0,0 +1,40 @@ + + + + + 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..7500bf0 --- /dev/null +++ b/account_ebics/static/description/index.html @@ -0,0 +1,572 @@ + + + + + + +README.rst + + + +
+ + +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 for EBICS 2.x +and fintech 7.2.7 or higher for EBICS 3.0.

+

SWIFT 3SKey support requires fintech 6.4 or higher.

+
+

+
+

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

+
+

+
+ +
+

+
+ +
+

+
+ +
+

+
+
    +
  • account_usability

    +

    Recommended if you have multiple financial journals. +This module adds a number of accounting menu entries such as bank statement list view +which allows to see all statements downloaded via the ir.cron automated EBICS download.

    +

    Cf. https://github.com/OCA/account-financial-tools

    +
  • +
+
+

+
+ +
+

+
+ +
+

+
+ +
+

+
+
    +
  • 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 to import externally generated keys & certificates (currently only 3SKey signature certificate)
  • +
  • For Odoo 16.0 the interaction with the OCA payment order and bank statement import modules (e.g. french CFONB) is not yet available.
  • +
+
+
+
+ + diff --git a/account_ebics/views/ebics_config_views.xml b/account_ebics/views/ebics_config_views.xml new file mode 100644 index 0000000..f7220d9 --- /dev/null +++ b/account_ebics/views/ebics_config_views.xml @@ -0,0 +1,93 @@ + + + + + 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..e677d3c --- /dev/null +++ b/account_ebics/views/ebics_file_format_views.xml @@ -0,0 +1,89 @@ + + + + + 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..f64d4bb --- /dev/null +++ b/account_ebics/views/ebics_file_views.xml @@ -0,0 +1,256 @@ + + + + + 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..11bb02e --- /dev/null +++ b/account_ebics/views/ebics_userid_views.xml @@ -0,0 +1,160 @@ + + + + + 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..b3e3b23 --- /dev/null +++ b/account_ebics/views/menu.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + 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..77faf6b --- /dev/null +++ b/account_ebics/wizards/ebics_change_passphrase.py @@ -0,0 +1,68 @@ +# Copyright 2009-2023 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 err: + raise UserError(str(err)) from err + 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..bb24f0a --- /dev/null +++ b/account_ebics/wizards/ebics_change_passphrase.xml @@ -0,0 +1,44 @@ + + + + + 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..12eb03c --- /dev/null +++ b/account_ebics/wizards/ebics_xfer.py @@ -0,0 +1,646 @@ +# Copyright 2009-2023 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 ( + BusinessTransactionFormat, + EbicsBank, + EbicsClient, + EbicsFunctionalError, + EbicsKeyRing, + EbicsTechnicalError, + EbicsUser, + 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", + ) + test_mode = fields.Boolean( + 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.env.context.copy() + self.note = "" + err_cnt = 0 + client = self._setup_client() + if not client: + err_cnt += 1 + self.note += ( + _("EBICS client setup failed for connection '%s'") + % self.ebics_config_id.name + ) + else: + 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 == "BTD": + btf = BusinessTransactionFormat( + df.btf_service, + df.btf_message, + scope=df.btf_scope or None, + option=df.btf_option or None, + container=df.btf_container or None, + version=df.btf_version or None, + variant=df.btf_variant or None, + format=df.btf_format or None, + ) + data = client.BTD(btf, start=date_from, end=date_to) + elif 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: + err_cnt += 1 + e = exc_info() + self.note += "\n" + self.note += _( + "EBICS Functional Error during download of " + "File Format %(name)s (%(order_type)s):", + name=df.name, + order_type=df.order_type, + ) + self.note += "\n" + self.note += "{} (code: {})".format(e[1].message, e[1].code) + except EbicsTechnicalError: + err_cnt += 1 + e = exc_info() + self.note += "\n" + self.note += _( + "EBICS Technical Error during download of " + "File Format %(name)s (%(order_type)s):", + name=df.name, + order_type=df.order_type, + ) + self.note += "\n" + self.note += "{} (code: {})".format(e[1].message, e[1].code) + except EbicsVerificationError: + err_cnt += 1 + self.note += "\n" + self.note += _( + "EBICS Verification Error during download of " + "File Format %(name)s (%(order_type)s):", + name=df.name, + order_type=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 %(name)s (%(order_type)s):", + name=df.name, + order_type=df.order_type, + ) + self.note += "\n" + self.note += e.name + except Exception: + err_cnt += 1 + self.note += "\n" + self.note += _( + "Unknown Error during download of " + "File Format %(name)s (%(order_type)s):", + name=df.name, + order_type=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" + + ctx["err_cnt"] = err_cnt + 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.decodebytes(self.upload_data) + ef_format = self.format_id + OrderID = False + try: + order_type = self.order_type + if order_type == "BTU": + btf = BusinessTransactionFormat( + ef_format.btf_service, + ef_format.btf_message, + scope=ef_format.btf_scope or None, + option=ef_format.btf_option or None, + container=ef_format.btf_container or None, + version=ef_format.btf_version or None, + variant=ef_format.btf_variant or None, + format=ef_format.btf_format or None, + ) + kwargs = {} + if self.test_mode: + kwargs["TEST"] = "TRUE" + OrderID = client.BTU(btf, upload_data, **kwargs) + elif 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 suffix and 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 += "{} (code: {})".format(e[1].message, e[1].code) + except EbicsTechnicalError: + e = exc_info() + self.note += "\n" + self.note += _("EBICS Technical Error:") + self.note += "\n" + self.note += "{} (code: {})".format(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": + OrderID = self.ebics_config_id._get_order_number() + self.ebics_config_id.sudo()._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.xxx.cfonb120.stm.rfi": 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 = base_fn + suffix = file_format.suffix + if suffix and not fn.endswith(suffix): + fn = ".".join([fn, 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. + """ + + 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..0d28980 --- /dev/null +++ b/account_ebics/wizards/ebics_xfer.xml @@ -0,0 +1,137 @@ + + + + + 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_batch/README.rst b/account_ebics_batch/README.rst new file mode 100644 index 0000000..80f2d09 --- /dev/null +++ b/account_ebics_batch/README.rst @@ -0,0 +1,50 @@ +.. image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: https://www.gnu.org/licenses/agpl + :alt: License: AGPL-3 + +============================================ +Module to enable batch import of EBICS files +============================================ + +This module adds a cron job for the automated import of EBICS files. + +| + +A Log is created during the import in order to document import errors. +If errors have been detected, the Batch Import Log state is set to 'error'. + +When all EBICS Files have been imported correctly, the Batch Import Log state is set to 'done'. + +| + +The user can reprocess the imported EBICS files in status 'draft' via the Log object 'REPROCESS' button until all errors have been cleared. + +As an alternative, the user can force the Batch Import Log state to 'done' +(e.g. when the errors have been circumvented via manual encoding or the reprocessing of a single EBICS file). + +| + +Configuration +============= + +Adapt the 'EBICS Batch Import' ir.cron job created during the module installation. + +The cron job calls the following python method: + +| + +.. code-block:: python + + _batch_import() + + +The EBICS download will be performed on all confirmed EBICS connections. + +You can limit the automated operation to a subset of your EBICS connections via the ebics_config_ids parameter, e.g. + +| + +.. code-block:: python + + _batch_import(ebics_config_ids=[1,3]) + diff --git a/account_ebics_batch/__init__.py b/account_ebics_batch/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/account_ebics_batch/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_ebics_batch/__manifest__.py b/account_ebics_batch/__manifest__.py new file mode 100644 index 0000000..4f71eab --- /dev/null +++ b/account_ebics_batch/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2009-2023 Noviat. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "EBICS Files batch import", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Noviat", + "website": "https://www.noviat.com", + "category": "Accounting & Finance", + "summary": "EBICS Files automated import and processing", + "depends": ["account_ebics"], + "data": [ + "security/ir.model.access.csv", + "data/ir_cron_data.xml", + "views/ebics_batch_log_views.xml", + "views/menu.xml", + ], + "installable": True, +} diff --git a/account_ebics_batch/data/ir_cron_data.xml b/account_ebics_batch/data/ir_cron_data.xml new file mode 100644 index 0000000..2726b75 --- /dev/null +++ b/account_ebics_batch/data/ir_cron_data.xml @@ -0,0 +1,17 @@ + + + + + EBICS Batch Import + + code + model._batch_import() + + 1 + days + -1 + + + + + diff --git a/account_ebics_batch/models/__init__.py b/account_ebics_batch/models/__init__.py new file mode 100644 index 0000000..740bafa --- /dev/null +++ b/account_ebics_batch/models/__init__.py @@ -0,0 +1 @@ +from . import ebics_batch_log diff --git a/account_ebics_batch/models/ebics_batch_log.py b/account_ebics_batch/models/ebics_batch_log.py new file mode 100644 index 0000000..74d8b25 --- /dev/null +++ b/account_ebics_batch/models/ebics_batch_log.py @@ -0,0 +1,190 @@ +# Copyright 2009-2023 Noviat. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from sys import exc_info +from traceback import format_exception + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class EbicsBatchLog(models.Model): + _name = "ebics.batch.log" + _description = "Object to store EBICS Batch Import Logs" + _order = "create_date desc" + + date_from = fields.Date() + date_to = fields.Date() + ebics_config_ids = fields.Many2many( + comodel_name="ebics.config", string="EBICS Configurations" + ) + log_ids = fields.One2many( + comodel_name="ebics.batch.log.item", + inverse_name="log_id", + string="Batch Import Log Items", + readonly=True, + ) + file_ids = fields.Many2many( + comodel_name="ebics.file", + string="Batch Import EBICS Files", + readonly=True, + ) + file_count = fields.Integer( + string="EBICS Files Count", compute="_compute_ebics_files_fields", readonly=True + ) + has_draft_files = fields.Boolean(compute="_compute_ebics_files_fields") + state = fields.Selection( + selection=[("draft", "Draft"), ("error", "Error"), ("done", "Done")], + required=True, + readonly=True, + default="draft", + ) + + @api.depends("file_ids") + def _compute_ebics_files_fields(self): + for rec in self: + rec.has_draft_files = "draft" in rec.file_ids.mapped("state") + rec.file_count = len(rec.file_ids) + + def unlink(self): + for log in self: + if log.state != "draft": + raise UserError(_("Only log objects in state 'draft' can be deleted !")) + return super().unlink() + + def button_draft(self): + self.state = "draft" + + def button_done(self): + self.state = "done" + + def reprocess(self): + import_dict = {"errors": []} + self._ebics_process(import_dict) + self._finalise_processing(import_dict) + + def view_ebics_files(self): + action = self.env["ir.actions.actions"]._for_xml_id( + "account_ebics.ebics_file_action_download" + ) + action["domain"] = [("id", "in", self.file_ids.ids)] + return action + + def _batch_import(self, ebics_config_ids=None, date_from=None, date_to=None): + """ + Call this method from a cron job to automate the EBICS import. + """ + log_model = self.env["ebics.batch.log"] + import_dict = {"errors": []} + configs = self.env["ebics.config"].browse(ebics_config_ids) or self.env[ + "ebics.config" + ].search( + [ + ("company_ids", "in", self.env.user.company_ids.ids), + ("state", "=", "confirm"), + ] + ) + log = log_model.create( + { + "ebics_config_ids": [(6, 0, configs.ids)], + "date_from": date_from, + "date_to": date_to, + } + ) + ebics_file_ids = [] + for config in configs: + err_msg = ( + _("Error while processing EBICS connection '%s' :\n") % config.name + ) + if config.state == "draft": + import_dict["errors"].append( + err_msg + + _( + "Please set state to 'Confirm' and " + "Reprocess this EBICS Import Log." + ) + ) + continue + try: + with self.env.cr.savepoint(): + ebics_file_ids += self._ebics_import( + config, date_from, date_to, import_dict + ) + except UserError as e: + import_dict["errors"].append(err_msg + " ".join(e.args)) + except Exception: + tb = "".join(format_exception(*exc_info())) + import_dict["errors"].append(err_msg + tb) + log.file_ids = [(6, 0, ebics_file_ids)] + try: + with self.env.cr.savepoint(): + log._ebics_process(import_dict) + except UserError as e: + import_dict["errors"].append(err_msg + " ".join(e.args)) + except Exception: + tb = "".join(format_exception(*exc_info())) + import_dict["errors"].append(err_msg + tb) + log._finalise_processing(import_dict) + + def _finalise_processing(self, import_dict): + log_item_model = self.env["ebics.batch.log.item"] + state = self.has_draft_files and "draft" or "done" + note = "" + error_count = 0 + if import_dict["errors"]: + state = "error" + note = "\n\n".join(import_dict["errors"]) + error_count = len(import_dict["errors"]) + log_item_model.create( + { + "log_id": self.id, + "state": state, + "note": note, + "error_count": error_count, + } + ) + self.state = state + + def _ebics_import(self, config, date_from, date_to, import_dict): + xfer_wiz = ( + self.env["ebics.xfer"] + .with_context(ebics_download=True) + .create( + { + "ebics_config_id": config.id, + "date_from": date_from, + "date_to": date_to, + } + ) + ) + xfer_wiz._onchange_ebics_config_id() + res = xfer_wiz.ebics_download() + file_ids = res["context"].get("ebics_file_ids") + if res["context"]["err_cnt"]: + import_dict["errors"].append(xfer_wiz.note) + return file_ids + + def _ebics_process(self, import_dict): + to_process = self.file_ids.filtered(lambda r: r.state == "draft") + for ebics_file in to_process: + ebics_file.process() + + +class EbicsBatchLogItem(models.Model): + _name = "ebics.batch.log.item" + _description = "Object to store EBICS Batch Import Log Items" + _order = "create_date desc" + + log_id = fields.Many2one( + comodel_name="ebics.batch.log", + string="Batch Object", + ondelete="cascade", + readonly=True, + ) + state = fields.Selection( + selection=[("draft", "Draft"), ("error", "Error"), ("done", "Done")], + required=True, + readonly=True, + ) + note = fields.Text(string="Batch Import Log", readonly=True) + error_count = fields.Integer(string="Number of Errors", required=True, default=0) diff --git a/account_ebics_batch/security/ir.model.access.csv b/account_ebics_batch/security/ir.model.access.csv new file mode 100644 index 0000000..af76670 --- /dev/null +++ b/account_ebics_batch/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ebics_batch_log,ebics.batch.log,model_ebics_batch_log,account.group_account_invoice,1,1,1,1 +access_ebics_batch_log_item,ebics.batch.log.item,model_ebics_batch_log_item,account.group_account_invoice,1,1,1,1 diff --git a/account_ebics_batch/static/description/icon.png b/account_ebics_batch/static/description/icon.png new file mode 100644 index 0000000..889d129 Binary files /dev/null and b/account_ebics_batch/static/description/icon.png differ diff --git a/account_ebics_batch/views/ebics_batch_log_views.xml b/account_ebics_batch/views/ebics_batch_log_views.xml new file mode 100644 index 0000000..8e3ae59 --- /dev/null +++ b/account_ebics_batch/views/ebics_batch_log_views.xml @@ -0,0 +1,118 @@ + + + + + ebics.batch.log.search + ebics.batch.log + + + + + + + + + + + + + + + + ebics.batch.log.tree + ebics.batch.log + + + + + + + + + + + ebics.batch.log.form + ebics.batch.log + +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + EBICS Batch Import Logs + ir.actions.act_window + ebics.batch.log + tree,form + + + + +
diff --git a/account_ebics_batch/views/menu.xml b/account_ebics_batch/views/menu.xml new file mode 100644 index 0000000..21343b8 --- /dev/null +++ b/account_ebics_batch/views/menu.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/account_ebics_batch_payment/README.rst b/account_ebics_batch_payment/README.rst new file mode 100644 index 0000000..1a28c97 --- /dev/null +++ b/account_ebics_batch_payment/README.rst @@ -0,0 +1,24 @@ +.. image:: https://img.shields.io/badge/licence-LGPL--3-blue.svg + :target: https://www.gnu.org/licenses/lpgl + :alt: License: AGPL-3 + +============================== +Upload Batch Payment via EBICS +============================== + +This module allows to upload a Batch Payment to the bank via the EBICS protocol. + +Installation +============ + +This module depends upon the following modules: + +- account_ebics (cf. https://github.com/Noviat/account_ebics) +- account_ebics_oe (cf. https://github.com/Noviat/account_ebics) +- account_batch_payment (Odoo Enterprise) + +Usage +===== + +Create your Batch Payment and generate the bank file. +Upload the generated file via the 'EBICS Upload' button on the batch payment. diff --git a/account_ebics_batch_payment/__init__.py b/account_ebics_batch_payment/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/account_ebics_batch_payment/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_ebics_batch_payment/__manifest__.py b/account_ebics_batch_payment/__manifest__.py new file mode 100644 index 0000000..f1dca19 --- /dev/null +++ b/account_ebics_batch_payment/__manifest__.py @@ -0,0 +1,14 @@ +# Copyright 2009-2023 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +{ + "name": "Upload Batch Payment via EBICS", + "version": "16.0.1.0.0", + "license": "LGPL-3", + "author": "Noviat", + "website": "https://www.noviat.com", + "category": "Accounting & Finance", + "depends": ["account_ebics", "account_batch_payment"], + "data": ["views/account_batch_payment_views.xml"], + "installable": True, +} diff --git a/account_ebics_batch_payment/models/__init__.py b/account_ebics_batch_payment/models/__init__.py new file mode 100644 index 0000000..015ee74 --- /dev/null +++ b/account_ebics_batch_payment/models/__init__.py @@ -0,0 +1 @@ +from . import account_batch_payment diff --git a/account_ebics_batch_payment/models/account_batch_payment.py b/account_ebics_batch_payment/models/account_batch_payment.py new file mode 100644 index 0000000..c313252 --- /dev/null +++ b/account_ebics_batch_payment/models/account_batch_payment.py @@ -0,0 +1,53 @@ +# Copyright 2009-2023 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +from odoo import _, models +from odoo.exceptions import UserError + + +class AccountBatchPayment(models.Model): + _inherit = "account.batch.payment" + + def ebics_upload(self): + self.ensure_one() + ctx = self.env.context.copy() + + origin = _("Batch Payment") + ": " + self.name + ebics_config = self.env["ebics.config"].search( + [ + ("journal_ids", "=", self.journal_id.id), + ("state", "=", "confirm"), + ] + ) + if not ebics_config: + raise UserError( + _("No active EBICS configuration available " "for the selected bank.") + ) + if len(ebics_config) == 1: + ctx["default_ebics_config_id"] = ebics_config.id + ctx.update( + { + "default_upload_data": self.export_file, + "default_upload_fname": self.export_filename, + "origin": origin, + "force_comany": self.journal_id.company_id.id, + } + ) + + ebics_xfer = self.env["ebics.xfer"].with_context(**ctx).create({}) + ebics_xfer._onchange_ebics_config_id() + ebics_xfer._onchange_upload_data() + ebics_xfer._onchange_format_id() + view = self.env.ref("account_ebics.ebics_xfer_view_form_upload") + act = { + "name": _("EBICS Upload"), + "view_type": "form", + "view_mode": "form", + "res_model": "ebics.xfer", + "view_id": view.id, + "res_id": ebics_xfer.id, + "type": "ir.actions.act_window", + "target": "new", + "context": ctx, + } + return act diff --git a/account_ebics_batch_payment/static/description/icon.png b/account_ebics_batch_payment/static/description/icon.png new file mode 100644 index 0000000..889d129 Binary files /dev/null and b/account_ebics_batch_payment/static/description/icon.png differ diff --git a/account_ebics_batch_payment/views/account_batch_payment_views.xml b/account_ebics_batch_payment/views/account_batch_payment_views.xml new file mode 100644 index 0000000..ef1f564 --- /dev/null +++ b/account_ebics_batch_payment/views/account_batch_payment_views.xml @@ -0,0 +1,20 @@ + + + + + account.batch.payment.form + account.batch.payment + + + + + + + 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..3c4cdc2 --- /dev/null +++ b/account_ebics_oca_statement_import/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2020-2023 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": "16.0.1.0.0", + "author": "Noviat", + "website": "https://www.noviat.com", + "category": "Hidden", + "license": "LGPL-3", + "depends": [ + "account_ebics", + "account_statement_import", + ], + # installable False unit OCA statement import becomes + # available for 16.0 + "installable": False, + "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..86b798d --- /dev/null +++ b/account_ebics_oca_statement_import/wizards/account_statement_import.py @@ -0,0 +1,60 @@ +# 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 + 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..b442879 --- /dev/null +++ b/account_ebics_oe/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2020-2023 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": "16.0.1.0.0", + "author": "Noviat", + "website": "https://www.noviat.com", + "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..4f9f379 --- /dev/null +++ b/account_ebics_oe/views/account_ebics_menu.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/account_ebics_payment_order/README.rst b/account_ebics_payment_order/README.rst new file mode 100644 index 0000000..c6ecb6d --- /dev/null +++ b/account_ebics_payment_order/README.rst @@ -0,0 +1,28 @@ +.. image:: https://img.shields.io/badge/licence-LGPL--3-blue.svg + :target: https://www.gnu.org/licenses/lpgl + :alt: License: AGPL-3 + +============================== +Upload Payment Order via EBICS +============================== + +This module allows to upload a Payment Order to the bank via the EBICS protocol. + +Installation +============ + +This module depends upon the following modules (cf. apps.odoo.com): + +- account_ebics +- account_payment_order + +Usage +===== + +Create your Payment Order and generate the bank file. +Upload the generated file via the 'EBICS Upload' button on the payment order. + +Known issues / Roadmap +====================== + + * Add support for multiple EBICS connections. diff --git a/account_ebics_payment_order/__init__.py b/account_ebics_payment_order/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/account_ebics_payment_order/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_ebics_payment_order/__manifest__.py b/account_ebics_payment_order/__manifest__.py new file mode 100644 index 0000000..b5be3f9 --- /dev/null +++ b/account_ebics_payment_order/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2009-2023 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +{ + "name": "Upload Payment Order via EBICS", + "version": "16.0.1.0.0", + "license": "LGPL-3", + "author": "Noviat", + "website": "https://www.noviat.com", + "category": "Accounting & Finance", + "depends": ["account_ebics", "account_payment_order"], + "data": [ + "views/account_payment_order_views.xml", + ], + # installable False unit OCA payment order becomes + # available for 16.0 + "installable": False, +} diff --git a/account_ebics_payment_order/models/__init__.py b/account_ebics_payment_order/models/__init__.py new file mode 100644 index 0000000..429f032 --- /dev/null +++ b/account_ebics_payment_order/models/__init__.py @@ -0,0 +1 @@ +from . import account_payment_order diff --git a/account_ebics_payment_order/models/account_payment_order.py b/account_ebics_payment_order/models/account_payment_order.py new file mode 100644 index 0000000..ecd9c78 --- /dev/null +++ b/account_ebics_payment_order/models/account_payment_order.py @@ -0,0 +1,74 @@ +# Copyright 2009-2023 Noviat. +# License LGPL-3 or later (http://www.gnu.org/licenses/lpgl). + +from odoo import _, models +from odoo.exceptions import UserError + + +class AccountPaymentOrder(models.Model): + _inherit = "account.payment.order" + + def ebics_upload(self): + self.ensure_one() + attach = self.env["ir.attachment"].search( + [("res_model", "=", self._name), ("res_id", "=", self.id)] + ) + if not attach: + raise UserError( + _( + "This payment order doesn't contains attachements." + "\nPlease generate first the Payment Order file first." + ) + ) + elif len(attach) > 1: + raise UserError( + _( + "This payment order contains multiple attachments." + "\nPlease remove the obsolete attachments or upload " + "the payment order file via the " + "EBICS Processing > EBICS Upload menu" + ) + ) + else: + origin = _("Payment Order") + ": " + self.name + ebics_config = self.env["ebics.config"].search( + [ + ("journal_ids", "=", self.journal_id.id), + ("state", "=", "confirm"), + ] + ) + if not ebics_config: + raise UserError( + _( + "No active EBICS configuration available " + "for the selected bank." + ) + ) + ctx = self.env.context.copy() + if len(ebics_config) == 1: + ctx["default_ebics_config_id"] = ebics_config.id + ctx.update( + { + "default_upload_data": attach.datas, + "default_upload_fname": attach.name, + "origin": origin, + "force_comany": self.company_id.id, + } + ) + ebics_xfer = self.env["ebics.xfer"].with_context(**ctx).create({}) + ebics_xfer._onchange_ebics_config_id() + ebics_xfer._onchange_upload_data() + ebics_xfer._onchange_format_id() + view = self.env.ref("account_ebics.ebics_xfer_view_form_upload") + act = { + "name": _("EBICS Upload"), + "view_type": "form", + "view_mode": "form", + "res_model": "ebics.xfer", + "view_id": view.id, + "res_id": ebics_xfer.id, + "type": "ir.actions.act_window", + "target": "new", + "context": ctx, + } + return act diff --git a/account_ebics_payment_order/static/description/icon.png b/account_ebics_payment_order/static/description/icon.png new file mode 100644 index 0000000..889d129 Binary files /dev/null and b/account_ebics_payment_order/static/description/icon.png differ diff --git a/account_ebics_payment_order/views/account_payment_order_views.xml b/account_ebics_payment_order/views/account_payment_order_views.xml new file mode 100644 index 0000000..bdadee4 --- /dev/null +++ b/account_ebics_payment_order/views/account_payment_order_views.xml @@ -0,0 +1,21 @@ + + + + + account.payment.order.form + account.payment.order + + + + + + + diff --git a/setup/.setuptools-odoo-make-default-ignore b/setup/.setuptools-odoo-make-default-ignore new file mode 100644 index 0000000..207e615 --- /dev/null +++ b/setup/.setuptools-odoo-make-default-ignore @@ -0,0 +1,2 @@ +# addons listed in this file are ignored by +# setuptools-odoo-make-default (one addon per line) diff --git a/setup/README b/setup/README new file mode 100644 index 0000000..a63d633 --- /dev/null +++ b/setup/README @@ -0,0 +1,2 @@ +To learn more about this directory, please visit +https://pypi.python.org/pypi/setuptools-odoo diff --git a/setup/account_ebics/odoo/addons/account_ebics b/setup/account_ebics/odoo/addons/account_ebics new file mode 120000 index 0000000..16c1742 --- /dev/null +++ b/setup/account_ebics/odoo/addons/account_ebics @@ -0,0 +1 @@ +../../../../account_ebics \ No newline at end of file diff --git a/setup/account_ebics/setup.py b/setup/account_ebics/setup.py new file mode 100644 index 0000000..28c57bb --- /dev/null +++ b/setup/account_ebics/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/account_ebics_batch/odoo/addons/account_ebics_batch b/setup/account_ebics_batch/odoo/addons/account_ebics_batch new file mode 120000 index 0000000..d1e7e96 --- /dev/null +++ b/setup/account_ebics_batch/odoo/addons/account_ebics_batch @@ -0,0 +1 @@ +../../../../account_ebics_batch \ No newline at end of file diff --git a/setup/account_ebics_batch/setup.py b/setup/account_ebics_batch/setup.py new file mode 100644 index 0000000..28c57bb --- /dev/null +++ b/setup/account_ebics_batch/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/account_ebics_batch_payment/odoo/addons/account_ebics_batch_payment b/setup/account_ebics_batch_payment/odoo/addons/account_ebics_batch_payment new file mode 120000 index 0000000..1e5ed4c --- /dev/null +++ b/setup/account_ebics_batch_payment/odoo/addons/account_ebics_batch_payment @@ -0,0 +1 @@ +../../../../account_ebics_batch_payment \ No newline at end of file diff --git a/setup/account_ebics_batch_payment/setup.py b/setup/account_ebics_batch_payment/setup.py new file mode 100644 index 0000000..28c57bb --- /dev/null +++ b/setup/account_ebics_batch_payment/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/account_ebics_oe/odoo/addons/account_ebics_oe b/setup/account_ebics_oe/odoo/addons/account_ebics_oe new file mode 120000 index 0000000..bcdee8e --- /dev/null +++ b/setup/account_ebics_oe/odoo/addons/account_ebics_oe @@ -0,0 +1 @@ +../../../../account_ebics_oe \ No newline at end of file diff --git a/setup/account_ebics_oe/setup.py b/setup/account_ebics_oe/setup.py new file mode 100644 index 0000000..28c57bb --- /dev/null +++ b/setup/account_ebics_oe/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)