From a1fc381121d17b8450a1651586b5d8cc8e5a0ae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Didderen?= Date: Tue, 27 Oct 2026 21:27:17 +0100 Subject: [PATCH] [IMP] account_ebics - Add unit tests Add 97 tests: - account_ebics: order numbers, camt and CFONB split per account, journal lookup, duplicate statements, download and upload wizards, key initialisation (INI/HIA/HPB), passphrase change, admin orders. - account_ebics_batch: scheduled import, import log, reprocess. - account_ebics_payment_order / account_ebics_batch_payment: upload of the payment file and link to the payment document. - account_ebics_statement_import_test (new, test-only, not to install in production): installs the OCA camt and CFONB parsers in CI and runs the end-to-end statement import. The bank is simulated by patching ebics.xfer._setup_client, or the fintech client classes for the key initialisation; keys, certificates and the INI letter are real, created in a temporary directory. --- .copier-answers.yml | 2 +- .github/workflows/test.yml | 2 +- account_ebics/tests/__init__.py | 7 + account_ebics/tests/common.py | 198 ++++++++++++ .../tests/samples/camt053_currency_suffix.xml | 2 + .../tests/samples/camt053_hvb_clav_fx.xml | 2 + .../tests/samples/camt053_multi_account.xml | 2 + .../tests/samples/camt053_no_account.xml | 2 + .../tests/samples/camt053_no_entries.xml | 2 + .../tests/samples/camt053_sparkasse.xml | 2 + .../tests/samples/cfonb120_bad_length.dat | 1 + .../samples/cfonb120_lf_minimal_no_05.dat | 10 + .../samples/cfonb120_lf_multi_account_05.dat | 14 + .../tests/samples/cfonb120_lf_rich_05.dat | 14 + .../samples/cfonb120_no_eol_empty_only.dat | 1 + .../cfonb120_no_eol_multi_account_mixed.dat | 1 + .../cfonb120_no_eol_single_account.dat | 1 + account_ebics/tests/samples/invalid.xml | 1 + account_ebics/tests/samples/ptk_protocol.txt | 3 + .../tests/test_ebics_admin_wizards.py | 152 ++++++++++ account_ebics/tests/test_ebics_config.py | 52 ++++ account_ebics/tests/test_ebics_file.py | 170 +++++++++++ account_ebics/tests/test_ebics_file_split.py | 232 ++++++++++++++ account_ebics/tests/test_ebics_userid.py | 284 ++++++++++++++++++ account_ebics/tests/test_ebics_xfer.py | 245 +++++++++++++++ account_ebics/tests/test_ebics_xfer_client.py | 115 +++++++ account_ebics_batch/__manifest__.py | 2 +- account_ebics_batch/tests/__init__.py | 1 + .../tests/test_ebics_batch_import.py | 95 ++++++ account_ebics_batch_payment/__manifest__.py | 2 +- account_ebics_batch_payment/tests/__init__.py | 1 + .../tests/test_batch_payment_upload.py | 77 +++++ .../README.rst | 15 + .../__init__.py | 0 .../__manifest__.py | 17 ++ .../pyproject.toml | 3 + .../static/description/icon.png | Bin 0 -> 19480 bytes .../tests/__init__.py | 1 + .../tests/test_oe_statement_import.py | 66 ++++ account_ebics_payment_order/__manifest__.py | 2 +- account_ebics_payment_order/tests/__init__.py | 1 + .../tests/test_payment_order_upload.py | 95 ++++++ .../README.rst | 16 + .../__init__.py | 0 .../__manifest__.py | 18 ++ .../pyproject.toml | 3 + .../static/description/icon.png | Bin 0 -> 19480 bytes .../tests/__init__.py | 1 + .../tests/test_statement_import.py | 145 +++++++++ test-requirements.txt | 2 + 50 files changed, 2075 insertions(+), 5 deletions(-) create mode 100644 account_ebics/tests/__init__.py create mode 100644 account_ebics/tests/common.py create mode 100644 account_ebics/tests/samples/camt053_currency_suffix.xml create mode 100644 account_ebics/tests/samples/camt053_hvb_clav_fx.xml create mode 100644 account_ebics/tests/samples/camt053_multi_account.xml create mode 100644 account_ebics/tests/samples/camt053_no_account.xml create mode 100644 account_ebics/tests/samples/camt053_no_entries.xml create mode 100644 account_ebics/tests/samples/camt053_sparkasse.xml create mode 100644 account_ebics/tests/samples/cfonb120_bad_length.dat create mode 100644 account_ebics/tests/samples/cfonb120_lf_minimal_no_05.dat create mode 100644 account_ebics/tests/samples/cfonb120_lf_multi_account_05.dat create mode 100644 account_ebics/tests/samples/cfonb120_lf_rich_05.dat create mode 100644 account_ebics/tests/samples/cfonb120_no_eol_empty_only.dat create mode 100644 account_ebics/tests/samples/cfonb120_no_eol_multi_account_mixed.dat create mode 100644 account_ebics/tests/samples/cfonb120_no_eol_single_account.dat create mode 100644 account_ebics/tests/samples/invalid.xml create mode 100644 account_ebics/tests/samples/ptk_protocol.txt create mode 100644 account_ebics/tests/test_ebics_admin_wizards.py create mode 100644 account_ebics/tests/test_ebics_config.py create mode 100644 account_ebics/tests/test_ebics_file.py create mode 100644 account_ebics/tests/test_ebics_file_split.py create mode 100644 account_ebics/tests/test_ebics_userid.py create mode 100644 account_ebics/tests/test_ebics_xfer.py create mode 100644 account_ebics/tests/test_ebics_xfer_client.py create mode 100644 account_ebics_batch/tests/__init__.py create mode 100644 account_ebics_batch/tests/test_ebics_batch_import.py create mode 100644 account_ebics_batch_payment/tests/__init__.py create mode 100644 account_ebics_batch_payment/tests/test_batch_payment_upload.py create mode 100644 account_ebics_oe_statement_import_test/README.rst create mode 100644 account_ebics_oe_statement_import_test/__init__.py create mode 100644 account_ebics_oe_statement_import_test/__manifest__.py create mode 100644 account_ebics_oe_statement_import_test/pyproject.toml create mode 100644 account_ebics_oe_statement_import_test/static/description/icon.png create mode 100644 account_ebics_oe_statement_import_test/tests/__init__.py create mode 100644 account_ebics_oe_statement_import_test/tests/test_oe_statement_import.py create mode 100644 account_ebics_payment_order/tests/__init__.py create mode 100644 account_ebics_payment_order/tests/test_payment_order_upload.py create mode 100644 account_ebics_statement_import_test/README.rst create mode 100644 account_ebics_statement_import_test/__init__.py create mode 100644 account_ebics_statement_import_test/__manifest__.py create mode 100644 account_ebics_statement_import_test/pyproject.toml create mode 100644 account_ebics_statement_import_test/static/description/icon.png create mode 100644 account_ebics_statement_import_test/tests/__init__.py create mode 100644 account_ebics_statement_import_test/tests/test_statement_import.py diff --git a/.copier-answers.yml b/.copier-answers.yml index 395641e..b09966a 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -2,7 +2,7 @@ _commit: v9.1.0 _src_path: git@github.com:Noviat-CI/addons-repo-template.git ci_disable_autoinstall_modules: account_asset -community_excluded_modules: account_ebics_batch_payment,account_ebics_oe +community_excluded_modules: account_ebics_batch_payment,account_ebics_oe,account_ebics_oe_statement_import_test odoo_test_flavor: Both odoo_version: 18.0 org_slug: Noviat diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d2601b1..bc61fa9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,7 +50,7 @@ jobs: name: test with Odoo Community odoo_enterprise : 0 exclude_modules : "account_asset" - skip: "account_ebics_batch_payment,account_ebics_oe" + skip: "account_ebics_batch_payment,account_ebics_oe,account_ebics_oe_statement_import_test" - container: ghcr.io/noviat-ci/odoo-ci:18.0-stable name: test with Odoo Enterprise odoo_enterprise : 1 diff --git a/account_ebics/tests/__init__.py b/account_ebics/tests/__init__.py new file mode 100644 index 0000000..276c594 --- /dev/null +++ b/account_ebics/tests/__init__.py @@ -0,0 +1,7 @@ +from . import test_ebics_admin_wizards +from . import test_ebics_config +from . import test_ebics_file +from . import test_ebics_file_split +from . import test_ebics_userid +from . import test_ebics_xfer +from . import test_ebics_xfer_client diff --git a/account_ebics/tests/common.py b/account_ebics/tests/common.py new file mode 100644 index 0000000..b9b57f4 --- /dev/null +++ b/account_ebics/tests/common.py @@ -0,0 +1,198 @@ +# Copyright 2026 Noviat. +# License LGPL-3 or later (https://www.gnu.org/licenses/lgpl). + +import base64 +from contextlib import contextmanager +from unittest.mock import patch + +from odoo import Command, fields +from odoo.tools import file_open + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + +# Synthetic files: structures mirror real bank files, all values are invented. +SAMPLES_DIR = "account_ebics/tests/samples" + +# Bank accounts used in the samples (fictitious, checksum-valid). +IBAN_CMB = "FR7615589297011000000010111" +IBAN_CA833 = [ + "FR7613306000012000000020197", + "FR7613306000012000000020294", + "FR7613306000012000000020391", +] +IBAN_LCL = [ + "FR7630002004563000000030102", + "FR7630002004563000000030296", + "FR7630002004563000000030393", + "FR7630002004563000000030490", +] +IBAN_CA879 = [ + "FR7617906000324000000040136", + "FR7617906000324000000040233", + "FR7617906000324000000040330", + "FR7617906000324000000040427", +] +IBAN_CIC = "FR7630087331005000000050175" +IBAN_HVB = "DE54793200750010000001" +IBAN_SPK = "DE52305500000010000002" +IBAN_MULTI = ["DE69370400440010000003", "DE42370400440010000004"] +IBAN_SUFFIX = "DE15370400440010000005" + + +def read_sample(name): + with file_open(f"{SAMPLES_DIR}/{name}", "rb") as f: + return f.read() + + +class FakeEbicsClient: + """Stand-in for fintech.ebics.EbicsClient, returned by a patched + ebics.xfer._setup_client so that no keyring and no bank are needed. + + :param downloads: dict order type ("BTD", "FDL" or the EBICS 2 order + type) -> bytes (or dict of document name -> bytes) to return. + :param error: exception instance raised by every transfer call. + """ + + def __init__(self, downloads=None, order_id="N001", error=None): + self.downloads = downloads or {} + self.order_id = order_id + self.error = error + self.uploads = [] + self.confirmed = [] + self.last_trans_id = "TRANS0001" + + def _download(self, key): + if self.error: + raise self.error + return self.downloads[key] + + def _upload(self, key, data, kwargs): + if self.error: + raise self.error + self.uploads.append((key, data, kwargs)) + return self.order_id + + def BTD(self, btf, start=None, end=None, **kwargs): + return self._download("BTD") + + def FDL(self, filetype, start=None, end=None): + return self._download("FDL") + + def download(self, order_type, params=None): + return self._download(order_type) + + def BTU(self, btf, data, **kwargs): + return self._upload("BTU", data, kwargs) + + def FUL(self, filetype, data, **kwargs): + return self._upload("FUL", data, kwargs) + + def upload(self, order_type, data): + return self._upload(order_type, data, {}) + + def confirm_download(self, trans_id=None, success=True): + self.confirmed.append((trans_id, success)) + + +class EbicsTestCommon(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env.user.groups_id |= cls.env.ref("account_ebics.group_ebics_manager") + cls.eur = cls.env.ref("base.EUR") + cls.eur.active = True + cls.company = cls.env.company + cls.ebics_config = cls.env["ebics.config"].create( + { + "name": "Test Bank", + "ebics_host": "TESTHOST", + "ebics_url": "https://ebics.example.invalid/ebics", + "ebics_partner": "PARTNER1", + "ebics_version": "H004", + "state": "confirm", + } + ) + cls.ebics_userid = cls.env["ebics.userid"].create( + { + "name": "USER1", + "ebics_config_id": cls.ebics_config.id, + "user_ids": [Command.set(cls.env.user.ids)], + "company_ids": [Command.set(cls.company.ids)], + "ebics_passphrase": "test-passphrase", + "ebics_passphrase_store": True, + "state": "active_keys", + } + ) + cls.ff_camt053 = cls.env.ref("account_ebics.ebics_ff_C53") + cls.ff_cfonb120 = cls.env.ref( + "account_ebics.ebics_ff_FDL_camt_xxx_cfonb120_stm" + ) + cls.ff_btd_cfonb120 = cls.env.ref("account_ebics.ebics_ff_btf_cfonb120") + + @classmethod + def _create_bank_journal(cls, code, acc_number, currency=None): + # sudo: accountants may not create bank accounts (res.partner.bank) + journal = ( + cls.env["account.journal"] + .sudo() + .create( + { + "name": f"Bank {code}", + "code": code, + "type": "bank", + "bank_acc_number": acc_number, + "currency_id": (currency or cls.eur).id, + "company_id": cls.company.id, + } + ) + .sudo(False) + ) + cls.ebics_config.journal_ids = [Command.link(journal.id)] + return journal + + @classmethod + def _create_ebics_file(cls, sample, file_format, name=None): + return cls.env["ebics.file"].create( + { + "name": name or sample, + "data": base64.b64encode(read_sample(sample)), + "date": fields.Datetime.now(), + "format_id": file_format.id, + } + ) + + @staticmethod + def _new_res(): + return {"statement_ids": [], "notifications": []} + + @contextmanager + def _fake_client(self, client): + xfer_class = type(self.env["ebics.xfer"]) + with patch.object(xfer_class, "_setup_client", return_value=client): + yield client + + def _download_wizard(self, file_format, **vals): + return ( + self.env["ebics.xfer"] + .with_context(ebics_download=True) + .create( + { + "ebics_config_id": self.ebics_config.id, + "ebics_userid_id": self.ebics_userid.id, + "format_id": file_format.id, + **vals, + } + ) + ) + + def _upload_wizard(self, file_format, data=b"", fname="pay.xml", **vals): + return self.env["ebics.xfer"].create( + { + "ebics_config_id": self.ebics_config.id, + "ebics_userid_id": self.ebics_userid.id, + "format_id": file_format.id, + "upload_data": base64.b64encode(data), + "upload_fname": fname, + **vals, + } + ) diff --git a/account_ebics/tests/samples/camt053_currency_suffix.xml b/account_ebics/tests/samples/camt053_currency_suffix.xml new file mode 100644 index 0000000..a0e74c3 --- /dev/null +++ b/account_ebics/tests/samples/camt053_currency_suffix.xml @@ -0,0 +1,2 @@ + +MSG-TEST-00012026-10-13T22:00:00SUFFIX-STMT-112026-10-13T22:00:000010000005EUREUROPBD10.00CRDT
2026-10-13
CLBD15.00CRDT
2026-10-13
SUFFIX-00015.00CRDTBOOK
2026-10-13
2026-10-13
SUFFIX-0001PMNTICDTESCTNTRF+116DKSUFFIX-00015.00Rho refund
diff --git a/account_ebics/tests/samples/camt053_hvb_clav_fx.xml b/account_ebics/tests/samples/camt053_hvb_clav_fx.xml new file mode 100644 index 0000000..643e6db --- /dev/null +++ b/account_ebics/tests/samples/camt053_hvb_clav_fx.xml @@ -0,0 +1,2 @@ + +MSG-TEST-00012026-10-13T22:00:00HVB-STMT-000112026-10-13T22:00:00DE54793200750010000001EUROPBD5000.00CRDT
2026-10-13
CLBD3992.50CRDT
2026-10-13
CLAV3992.50CRDT
2026-10-13
HVB-0001150.00DBITBOOK
2026-10-13
2026-10-13
HVB-0001PMNTICDTESCTNTRF+116DKHVB-0001E2E-LAMBDA-118150.00Rechnung 2026-118 Lambda GmbH
HVB-0002857.50DBITBOOK
2026-10-13
2026-10-13
HVB-0002PMNTICDTESCTNTRF+116DKHVB-0002857.501000.0012.50Zahlung USD Mu Inc
diff --git a/account_ebics/tests/samples/camt053_multi_account.xml b/account_ebics/tests/samples/camt053_multi_account.xml new file mode 100644 index 0000000..d151433 --- /dev/null +++ b/account_ebics/tests/samples/camt053_multi_account.xml @@ -0,0 +1,2 @@ + +MSG-TEST-00012026-10-13T22:00:00MULTI-STMT-112026-10-13T22:00:00DE69370400440010000003EUROPBD100.00CRDT
2026-10-13
CLBD130.00CRDT
2026-10-13
MULTI-000130.00CRDTBOOK
2026-10-13
2026-10-13
MULTI-0001PMNTICDTESCTNTRF+116DKMULTI-000130.00Omikron payment
MULTI-STMT-212026-10-13T22:00:00DE42370400440010000004EUROPBD200.00CRDT
2026-10-13
CLBD180.00CRDT
2026-10-13
MULTI-000220.00DBITBOOK
2026-10-13
2026-10-13
MULTI-0002PMNTICDTESCTNTRF+116DKMULTI-000220.00Pi fee
diff --git a/account_ebics/tests/samples/camt053_no_account.xml b/account_ebics/tests/samples/camt053_no_account.xml new file mode 100644 index 0000000..d828f0d --- /dev/null +++ b/account_ebics/tests/samples/camt053_no_account.xml @@ -0,0 +1,2 @@ + +MSG-TEST-00012026-10-13T22:00:00NOACC-STMT-112026-10-13T22:00:00EUROPBD1.00CRDT
2026-10-13
CLBD2.00CRDT
2026-10-13
NOACC-00011.00CRDTBOOK
2026-10-13
2026-10-13
NOACC-0001PMNTICDTESCTNTRF+116DKNOACC-00011.00Sigma
diff --git a/account_ebics/tests/samples/camt053_no_entries.xml b/account_ebics/tests/samples/camt053_no_entries.xml new file mode 100644 index 0000000..14976f3 --- /dev/null +++ b/account_ebics/tests/samples/camt053_no_entries.xml @@ -0,0 +1,2 @@ + +MSG-TEST-00012026-10-13T22:00:00EMPTY-STMT-112026-10-13T22:00:00DE52305500000010000002EUROPBD720.00CRDT
2026-10-13
CLBD720.00CRDT
2026-10-13
diff --git a/account_ebics/tests/samples/camt053_sparkasse.xml b/account_ebics/tests/samples/camt053_sparkasse.xml new file mode 100644 index 0000000..b7bc978 --- /dev/null +++ b/account_ebics/tests/samples/camt053_sparkasse.xml @@ -0,0 +1,2 @@ + +MSG-TEST-00012026-10-13T22:00:00SPK-STMT-000112026-10-13T22:00:00DE52305500000010000002EUROPBD720.00CRDT
2026-10-13
CLBD1065.00CRDT
2026-10-13
SPK-0001400.00CRDTBOOK
2026-10-13
2026-10-13
SPK-0001PMNTICDTESCTNTRF+116DKSPK-0001400.00Gutschrift Nu KG
SPK-000255.00DBITBOOK
2026-10-13
2026-10-13
SPK-0002PMNTICDTESCTNTRF+116DKSPK-000255.00Lastschrift Xi Versicherung
diff --git a/account_ebics/tests/samples/cfonb120_bad_length.dat b/account_ebics/tests/samples/cfonb120_bad_length.dat new file mode 100644 index 0000000..2cbc1f5 --- /dev/null +++ b/account_ebics/tests/samples/cfonb120_bad_length.dat @@ -0,0 +1 @@ +0115589 29701EUR2 10000000101 131026 0000000015000{ \ No newline at end of file diff --git a/account_ebics/tests/samples/cfonb120_lf_minimal_no_05.dat b/account_ebics/tests/samples/cfonb120_lf_minimal_no_05.dat new file mode 100644 index 0000000..0068ab6 --- /dev/null +++ b/account_ebics/tests/samples/cfonb120_lf_minimal_no_05.dat @@ -0,0 +1,10 @@ +0117906 00032EUR2 40000000401 131026** COMPTE COURANT 0000000000100{ +0717906 00032EUR2 40000000401 131026--------------------------------------------------0000000000100{ +0117906 00032EUR2 40000000402 131026** COMPTE EPARGNE 0000000000200{ +0717906 00032EUR2 40000000402 131026--------------------------------------------------0000000000200{ +0117906 00032EUR2 40000000403 131026 0000000000300{ +0717906 00032EUR2 40000000403 131026 0000000000300{ +0117906 00032EUR2 40000000404 131026 0000000040000{ +0417906000000032EUR2 4000000040405131026 131026VIR ETA SA 0000000 0000000015000{ +0417906000000032EUR2 4000000040405131026 131026PRLV THETA ASSURANCES 0000000 0000000002100} +0717906 00032EUR2 40000000404 131026 0000000052900{ diff --git a/account_ebics/tests/samples/cfonb120_lf_multi_account_05.dat b/account_ebics/tests/samples/cfonb120_lf_multi_account_05.dat new file mode 100644 index 0000000..be1e5c9 --- /dev/null +++ b/account_ebics/tests/samples/cfonb120_lf_multi_account_05.dat @@ -0,0 +1,14 @@ +0113306 00001EUR2 20000000201 131026 0000000084000{ +0413306000000001EUR2 2000000020105131026 131026VIR SEPA RECU GAMMA SARL 0000000 0000000006400{ +0513306000000001EUR2 2000000020105131026 NBEGAMMA SARL +0513306000000001EUR2 2000000020105131026 LCCFACT 99812 +0513306000000001EUR2 2000000020105131026 REFE2E-GAMMA-1 +0413306000000001EUR2 2000000020105131026 131026PRLV SEPA DELTA ENERGIE 0000000 0000000001325N +0513306000000001EUR2 2000000020105131026 NPYDELTA ENERGIE +0513306000000001EUR2 2000000020105131026 RUMRUM-DELTA-0042 +0713306 00001EUR2 20000000201 131026 0000000089074E +0113306 00001EUR2 20000000202 131026 0000000012345{ +0713306 00001EUR2 20000000202 131026 0000000012345{ +0113306 00001EUR2 20000000203 131026 0000000000500{ +0413306000000001EUR2 2000000020305131026 131026FRAIS TENUE DE COMPTE 0000000 0000000000099} +0713306 00001EUR2 20000000203 131026 0000000000401{ diff --git a/account_ebics/tests/samples/cfonb120_lf_rich_05.dat b/account_ebics/tests/samples/cfonb120_lf_rich_05.dat new file mode 100644 index 0000000..211b0f2 --- /dev/null +++ b/account_ebics/tests/samples/cfonb120_lf_rich_05.dat @@ -0,0 +1,14 @@ +0130087 33100EUR2 50000000501 131026 0000000009900{ +0430087000033100EUR2 5000000050105131026 131026VIR SEPA IOTA DISTRIBUTION 0000000 0000000021500{ +0530087000033100EUR2 5000000050105131026 NOMIOTA DISTRIBUTION +0530087000033100EUR2 5000000050105131026 RIB30087331005000000050199 +0530087000033100EUR2 5000000050105131026 IBEFR7630087331005000000050199 +0530087000033100EUR2 5000000050105131026 CBECMCIFRPP +0530087000033100EUR2 5000000050105131026 LCCFACTURE 7731 +0530087000033100EUR2 5000000050105131026 RCNSALE +0530087000033100EUR2 5000000050105131026 REFE2E-IOTA-7731 +0430087000033100EUR2 5000000050105131026 131026PRLV SEPA KAPPA TELECOM 0000000 0000000000499R +0530087000033100EUR2 5000000050105131026 NPYKAPPA TELECOM +0530087000033100EUR2 5000000050105131026 RUMRUM-KAPPA-1 +0530087000033100EUR2 5000000050105131026 LIBABONNEMENT +0730087 33100EUR2 50000000501 131026 0000000030900A diff --git a/account_ebics/tests/samples/cfonb120_no_eol_empty_only.dat b/account_ebics/tests/samples/cfonb120_no_eol_empty_only.dat new file mode 100644 index 0000000..3c65d82 --- /dev/null +++ b/account_ebics/tests/samples/cfonb120_no_eol_empty_only.dat @@ -0,0 +1 @@ +0115589 29701EUR2 10000000101 131026 0000000015000{ 0715589 29701EUR2 10000000101 131026 0000000015000{ \ No newline at end of file diff --git a/account_ebics/tests/samples/cfonb120_no_eol_multi_account_mixed.dat b/account_ebics/tests/samples/cfonb120_no_eol_multi_account_mixed.dat new file mode 100644 index 0000000..0ee83fd --- /dev/null +++ b/account_ebics/tests/samples/cfonb120_no_eol_multi_account_mixed.dat @@ -0,0 +1 @@ +0130002 00456EUR2 30000000301 131026 0000000001000{ 0730002 00456EUR2 30000000301 131026 0000000001000{ 0130002 00456EUR2 30000000302 131026 0000000025000{ 0430002000000456EUR2 3000000030205131026 131026VIR SEPA EPSILON SAS 0000000 0000000007800{ 0530002000000456EUR2 3000000030205131026 LIBACOMPTE CHANTIER 12 0430002000000456EUR2 3000000030205131026 131026CB STATION ZETA 0000000 0000000000612} 0730002 00456EUR2 30000000302 131026 0000000032188{ 0130002 00456EUR2 30000000303 131026 0000000000000{ 0730002 00456EUR2 30000000303 131026 0000000000000{ 0130002 00456EUR2 30000000304 131026 0000000003200{ 0430002000000456EUR2 3000000030405131026 131026REMISE CHEQUES 0000000 0000000004500{ 0730002 00456EUR2 30000000304 131026 0000000007700{ \ No newline at end of file diff --git a/account_ebics/tests/samples/cfonb120_no_eol_single_account.dat b/account_ebics/tests/samples/cfonb120_no_eol_single_account.dat new file mode 100644 index 0000000..3654423 --- /dev/null +++ b/account_ebics/tests/samples/cfonb120_no_eol_single_account.dat @@ -0,0 +1 @@ +0115589 29701EUR2 10000000101 131026 0000000015000{ 0415589000029701EUR2 1000000010105131026 131026PRLV SEPA FOURNISSEUR ALPHA 0000000 0000000002504} 0515589000029701EUR2 1000000010105131026 LIBFACTURE 2026-0412 0515589000029701EUR2 1000000010105131026 RCNREF ALPHA 77881 0415589000029701EUR2 1000000010105131026 131026VIR SEPA CLIENT BETA 0000000 0000000012000{ 0515589000029701EUR2 1000000010105131026 LCCCOMMANDE 5521 0715589 29701EUR2 10000000101 131026 0000000024496{ \ No newline at end of file diff --git a/account_ebics/tests/samples/invalid.xml b/account_ebics/tests/samples/invalid.xml new file mode 100644 index 0000000..e7d8eec --- /dev/null +++ b/account_ebics/tests/samples/invalid.xml @@ -0,0 +1 @@ +this is not an xml document diff --git a/account_ebics/tests/samples/ptk_protocol.txt b/account_ebics/tests/samples/ptk_protocol.txt new file mode 100644 index 0000000..70c9e41 --- /dev/null +++ b/account_ebics/tests/samples/ptk_protocol.txt @@ -0,0 +1,3 @@ +Kundenprotokoll (PTK) +Auftragsart: C53 Auftragsnummer: A00T Teilnehmer: TESTUSER1 +Status: Auftrag erfolgreich verarbeitet diff --git a/account_ebics/tests/test_ebics_admin_wizards.py b/account_ebics/tests/test_ebics_admin_wizards.py new file mode 100644 index 0000000..0d671da --- /dev/null +++ b/account_ebics/tests/test_ebics_admin_wizards.py @@ -0,0 +1,152 @@ +# Copyright 2026 Noviat. +# License LGPL-3 or later (https://www.gnu.org/licenses/lgpl). + +import pprint +from unittest.mock import patch + +from fintech.ebics import EbicsFunctionalError, EbicsKeyRing, EbicsTechnicalError + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .test_ebics_userid import EbicsKeysCommon + +# Test passphrases for throwaway keys created in a temporary directory. +INIT_PHRASE = "init-passphrase" +NEXT_PHRASE = "next-passphrase" +OTHER_PHRASE = "other-passphrase" +WRONG_PHRASE = "wrong-passphrase" + + +@tagged("post_install", "-at_install") +class TestChangePassphrase(EbicsKeysCommon): + def setUp(self): + super().setUp() + self.userid = self._new_userid() + self._init_1(self.userid) + + def _change(self, **vals): + wizard = self.env["ebics.change.passphrase"].create( + {"ebics_userid_id": self.userid.id, **vals} + ) + wizard.change_passphrase() + return wizard + + def test_change_passphrase(self): + wizard = self._change( + old_pass=INIT_PHRASE, + new_pass=NEXT_PHRASE, + new_pass_check=NEXT_PHRASE, + ) + self.assertEqual(self.userid.ebics_passphrase, NEXT_PHRASE) + self.assertIn("has been changed", wizard.note) + keyring = EbicsKeyRing(keys=self.userid.ebics_keys_fn, passphrase=NEXT_PHRASE) + self.assertTrue(keyring["#USER"]) + + def test_wrong_old_passphrase(self): + with self.assertRaisesRegex(UserError, "Incorrect old passphrase"): + self._change( + old_pass=WRONG_PHRASE, + new_pass=NEXT_PHRASE, + new_pass_check=NEXT_PHRASE, + ) + + def test_verification_mismatch(self): + with self.assertRaisesRegex(UserError, "verification error"): + self._change(new_pass=NEXT_PHRASE, new_pass_check=OTHER_PHRASE) + + def test_new_equals_old(self): + with self.assertRaisesRegex(UserError, "equal to old"): + self._change(new_pass=INIT_PHRASE, new_pass_check=INIT_PHRASE) + + def test_wrong_old_passphrase_not_stored(self): + """Without a stored passphrase, fintech rejects the wrong one.""" + self.userid.ebics_passphrase_store = False + self.userid.ebics_passphrase = False + with self.assertRaisesRegex(UserError, "(?i)passphrase"): + self._change( + old_pass=WRONG_PHRASE, + new_pass=NEXT_PHRASE, + new_pass_check=NEXT_PHRASE, + ) + + def test_change_passphrase_not_stored(self): + """The new passphrase must not be written when storing is off.""" + self.userid.ebics_passphrase_store = False + self.userid.ebics_passphrase = False + wizard = self._change( + old_pass=INIT_PHRASE, + new_pass=NEXT_PHRASE, + new_pass_check=NEXT_PHRASE, + ) + self.assertFalse(self.userid.ebics_passphrase) + self.assertIn("has been changed", wizard.note) + keyring = EbicsKeyRing(keys=self.userid.ebics_keys_fn, passphrase=NEXT_PHRASE) + self.assertTrue(keyring["#USER"]) + + +class FakeAdminClient: + def __init__(self, error=None): + self.error = error + + def _order(self, name): + if self.error: + raise self.error + return {"order": name, "Services": [{"ServiceName": "SCT"}]} + + def HAA(self, parsed=False): + return self._order("HAA") + + def HPD(self, parsed=False): + return self._order("HPD") + + def HKD(self, parsed=False): + return self._order("HKD") + + def HTD(self, parsed=False): + return self._order("HTD") + + +@tagged("post_install", "-at_install") +class TestAdminOrder(EbicsKeysCommon): + def _run(self, order_type, client): + wizard = self.env["ebics.admin.order"].create( + { + "ebics_config_id": self.ebics_config.id, + "ebics_userid_id": self.ebics_userid.id, + "admin_order_type": order_type, + } + ) + wizard_class = type(wizard) + with patch.object(wizard_class, "_setup_client", return_value=client): + wizard.ebics_admin_order() + return wizard + + def test_admin_orders(self): + for order_type in ("HAA", "HPD", "HKD", "HTD"): + with self.subTest(order_type=order_type): + wizard = self._run(order_type, FakeAdminClient()) + expected = pprint.PrettyPrinter().pformat( + FakeAdminClient()._order(order_type) + ) + self.assertEqual(wizard.note, expected) + + def test_admin_order_errors(self): + cases = [ + (EbicsTechnicalError("061001", "EBICS_AUTHENTICATION_FAILED"), "Technical"), + ( + EbicsFunctionalError("091005", "EBICS_NO_DOWNLOAD_DATA_AVAILABLE"), + "Functional", + ), + ] + for error, expected in cases: + with self.subTest(error=type(error).__name__): + wizard = self._run("HAA", FakeAdminClient(error=error)) + self.assertIn( + f"EBICS {expected} Error during execution of order HAA", wizard.note + ) + self.assertIn(f"(code: {error.code})", wizard.note) + + def test_admin_order_client_setup_failure(self): + wizard = self._run("HPD", False) + self.assertIn("client setup failed", wizard.note) diff --git a/account_ebics/tests/test_ebics_config.py b/account_ebics/tests/test_ebics_config.py new file mode 100644 index 0000000..5641355 --- /dev/null +++ b/account_ebics/tests/test_ebics_config.py @@ -0,0 +1,52 @@ +# Copyright 2026 Noviat. +# License LGPL-3 or later (https://www.gnu.org/licenses/lgpl). + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import EbicsTestCommon + +IBAN_FOR_COMPANY_TEST = "FR7630004000031234567890143" + + +@tagged("post_install", "-at_install") +class TestEbicsConfig(EbicsTestCommon): + def test_update_order_number(self): + cases = [ + ("A000", "A001"), + ("A009", "A00A"), + ("A00Z", "A010"), + ("A0Z9", "A0ZA"), + ("AZZZ", "B000"), + ("ZZZY", "A000"), + # ZZZZ is accepted by the constraint, it must wrap as well + ("ZZZZ", "A000"), + ] + for current, expected in cases: + with self.subTest(current=current): + self.ebics_config._update_order_number(current) + self.assertEqual(self.ebics_config.order_number, expected) + + def test_order_number_pattern(self): + for valid in ("A000", "B1Z9", "ZZZZ"): + with self.subTest(valid=valid): + self.ebics_config.order_number = valid + # No 5-character case: Char(size=4) truncates before the constraint runs. + for invalid in ("a001", "A01", "1000", "A-01"): + with self.subTest(invalid=invalid), self.assertRaises(UserError): + self.ebics_config.order_number = invalid + + def test_unlink_confirmed_config_is_blocked(self): + with self.assertRaises(UserError): + self.ebics_config.unlink() + + def test_unlink_draft_config(self): + self.ebics_userid.unlink() + self.ebics_config.set_to_draft() + self.ebics_config.unlink() + self.assertFalse(self.ebics_config.exists()) + + def test_company_ids_follow_journals(self): + journal = self._create_bank_journal("TCMP", IBAN_FOR_COMPANY_TEST) + self.assertEqual(self.ebics_config.company_ids, journal.company_id) + self.assertEqual(journal.ebics_config_id, self.ebics_config) diff --git a/account_ebics/tests/test_ebics_file.py b/account_ebics/tests/test_ebics_file.py new file mode 100644 index 0000000..b9dcc68 --- /dev/null +++ b/account_ebics/tests/test_ebics_file.py @@ -0,0 +1,170 @@ +# Copyright 2026 Noviat. +# License LGPL-3 or later (https://www.gnu.org/licenses/lgpl). + +from unittest.mock import patch + +from odoo import Command +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import IBAN_SPK, EbicsTestCommon + + +@tagged("post_install", "-at_install") +class TestEbicsFile(EbicsTestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.journal = cls._create_bank_journal("TSPK", IBAN_SPK) + + def _statement(self, name, date="2026-10-13", import_format="camt053"): + statement = self.env["account.bank.statement"].create( + { + "name": name, + "journal_id": self.journal.id, + "line_ids": [ + Command.create( + { + "journal_id": self.journal.id, + "date": date, + "payment_ref": f"line {name}", + "amount": 10.0, + } + ) + ], + } + ) + statement.import_format = import_format + return statement + + def test_duplicate_statement_is_removed(self): + self._statement("ST-1") + duplicate = self._statement("ST-1") + res = self._new_res() + res["statement_ids"] = duplicate.ids + ebics_file = self._create_ebics_file("camt053_sparkasse.xml", self.ff_camt053) + kept = ebics_file._statement_duplicate_check(res, duplicate) + self.assertFalse(kept) + self.assertFalse(duplicate.exists()) + self.assertEqual(res["statement_ids"], []) + self.assertEqual(res["notifications"][0]["type"], "warning") + self.assertIn("ST-1", res["notifications"][0]["message"]) + + def test_duplicate_check_scope(self): + """A checked statement only collides with the same name, date and format.""" + # existing statements: same name, other date / same name, other format + self._statement("ST-2", date="2026-10-14") + self._statement("ST-2", import_format="cfonb120") + new = self._statement("ST-2") + # outside DUP_CHECK_FORMATS: never checked + unchecked = self._statement("ST-2", import_format="camt052") + statements = new | unchecked + res = self._new_res() + res["statement_ids"] = statements.ids + ebics_file = self._create_ebics_file("camt053_sparkasse.xml", self.ff_camt053) + kept = ebics_file._statement_duplicate_check(res, statements) + self.assertEqual(kept, statements) + self.assertFalse(res["notifications"]) + + def test_process_result_note(self): + ebics_file = self._create_ebics_file("camt053_sparkasse.xml", self.ff_camt053) + ebics_file.note_process = "" + statement = self._statement("ST-3") + res = { + "statement_ids": statement.ids, + "notifications": [ + {"type": "error", "message": "Broken entry", "details": "line 4"}, + {"type": "warning", "message": "Odd balance"}, + {"info": "Something to know"}, + "Plain text warning", + ], + } + ebics_file._process_download_result(res, file_format="camt053") + note = ebics_file.note_process + self.assertIn("Broken entry\nline 4", note) + self.assertIn("Number of errors: 1", note) + self.assertIn("Number of warnings: 3", note) + self.assertIn("1 bank statement has been imported", note) + self.assertEqual(ebics_file.bank_statement_ids, statement) + self.assertEqual(statement.import_format, "camt053") + self.assertEqual(ebics_file.company_ids, self.company) + + def test_process_result_empty_file(self): + ebics_file = self._create_ebics_file("camt053_sparkasse.xml", self.ff_camt053) + ebics_file.note_process = "" + ebics_file._process_download_result(self._new_res(), file_format="camt053") + self.assertIn("doesn't contain any transaction", ebics_file.note_process) + + def test_unlink_done_file_is_blocked(self): + ebics_file = self._create_ebics_file("camt053_sparkasse.xml", self.ff_camt053) + ebics_file.set_to_done() + with self.assertRaises(UserError): + ebics_file.unlink() + + def test_unlink_removes_statements(self): + ebics_file = self._create_ebics_file("camt053_sparkasse.xml", self.ff_camt053) + statement = self._statement("ST-4") + statement.ebics_file_id = ebics_file + ebics_file.unlink() + self.assertFalse(statement.exists()) + + def test_unlink_pain002_file(self): + """pain.002 processing is a placeholder: its files must stay removable.""" + ebics_file = self._create_ebics_file( + "ptk_protocol.txt", self.env.ref("account_ebics.ebics_ff_Z01") + ) + ebics_file.unlink() + self.assertFalse(ebics_file.exists()) + + def test_process_format_without_method(self): + """Modelled on a Sparkasse customer protocol (PTK): stored, not processed.""" + ptk = self.env["ebics.file.format"].create( + {"name": "PTK", "type": "down", "order_type": "PTK", "suffix": "txt"} + ) + ebics_file = self._create_ebics_file("ptk_protocol.txt", ptk) + with self.assertRaisesRegex(UserError, "no support"): + ebics_file.process() + + def test_check_import_module(self): + ebics_file = self._create_ebics_file("camt053_sparkasse.xml", self.ff_camt053) + self.assertTrue(ebics_file._check_import_module("account_ebics")) + self.assertFalse( + ebics_file._check_import_module("no_such_module", raise_if_not_found=False) + ) + with self.assertRaises(UserError): + ebics_file._check_import_module("no_such_module") + + def test_enterprise_import_does_not_commit_in_tests(self): + """The Enterprise path commits after each statement in production.""" + ebics_file = self._create_ebics_file("camt053_sparkasse.xml", self.ff_camt053) + statement = self._statement("ST-5") + st_datas = [{"acc_number": "0010000002", "journal_id": self.journal.id}] + + def create_statement(res, st_data): + res["statement_ids"].append(statement.id) + + file_class = type(ebics_file) + cursor_class = type(self.env.cr) + res = self._new_res() + with ( + patch.object( + file_class, "_create_bank_statement_oe", side_effect=create_statement + ), + patch.object(cursor_class, "commit", side_effect=AssertionError("commit")), + ): + ebics_file._process_bank_statement_oe(res, st_datas) + self.assertEqual(res["statement_ids"], statement.ids) + self.assertFalse(res["notifications"]) + + def test_enterprise_import_reports_errors(self): + ebics_file = self._create_ebics_file("camt053_sparkasse.xml", self.ff_camt053) + st_datas = [{"acc_number": "0010000002", "journal_id": self.journal.id}] + res = self._new_res() + with patch.object( + type(ebics_file), + "_create_bank_statement_oe", + side_effect=UserError("Journal mismatch"), + ): + ebics_file._process_bank_statement_oe(res, st_datas) + self.assertIn("Journal mismatch", res["notifications"][0]["message"]) + self.assertIn("0010000002", res["notifications"][0]["message"]) diff --git a/account_ebics/tests/test_ebics_file_split.py b/account_ebics/tests/test_ebics_file_split.py new file mode 100644 index 0000000..9837c6a --- /dev/null +++ b/account_ebics/tests/test_ebics_file_split.py @@ -0,0 +1,232 @@ +# Copyright 2026 Noviat. +# License LGPL-3 or later (https://www.gnu.org/licenses/lgpl). + +import base64 + +from lxml import etree + +from odoo.tests import tagged + +from .common import ( + IBAN_CA833, + IBAN_CA879, + IBAN_CIC, + IBAN_CMB, + IBAN_HVB, + IBAN_LCL, + IBAN_MULTI, + IBAN_SPK, + IBAN_SUFFIX, + EbicsTestCommon, +) + + +@tagged("post_install", "-at_install") +class TestSplitCfonb(EbicsTestCommon): + """EBICS files may hold several accounts and empty statements: they are + split per statement before being handed to the CFONB parser.""" + + def _split(self, sample): + ebics_file = self._create_ebics_file(sample, self.ff_cfonb120) + res = self._new_res() + return ebics_file._split_cfonb(res), res + + def _lines(self, st_data): + return base64.b64decode(st_data["data"]).decode("latin-1").splitlines() + + def test_no_line_terminator_single_account(self): + """Modelled on Crédit Mutuel de Bretagne: no line terminator at all.""" + journal = self._create_bank_journal("TCMB", IBAN_CMB) + datas, res = self._split("cfonb120_no_eol_single_account.dat") + self.assertFalse(res["notifications"]) + self.assertEqual(len(datas), 1) + self.assertEqual(datas[0]["acc_number"], "10000000101") + self.assertEqual(datas[0]["journal_id"], journal.id) + self.assertEqual(datas[0]["company_id"], self.company.id) + lines = self._lines(datas[0]) + self.assertEqual( + [x[:2] for x in lines], ["01", "04", "05", "05", "04", "05", "07"] + ) + self.assertTrue(all(len(x) == 120 for x in lines)) + + def test_empty_statements_only(self): + self._create_bank_journal("TCMB", IBAN_CMB) + datas, res = self._split("cfonb120_no_eol_empty_only.dat") + self.assertEqual(datas, []) + self.assertFalse(res["notifications"]) + + def test_multi_account_with_complement_lines(self): + """Modelled on Crédit Agricole: 3 accounts, the middle one empty, and a + positive-zero overpunch ('{') on its balance.""" + j1 = self._create_bank_journal("TCA1", IBAN_CA833[0]) + j3 = self._create_bank_journal("TCA3", IBAN_CA833[2]) + datas, res = self._split("cfonb120_lf_multi_account_05.dat") + self.assertFalse(res["notifications"]) + self.assertEqual([d["journal_id"] for d in datas], [j1.id, j3.id]) + self.assertEqual( + [x[:2] for x in self._lines(datas[0])], + ["01", "04", "05", "05", "05", "04", "05", "05", "07"], + ) + + def test_multi_account_missing_journal(self): + self._create_bank_journal("TCA1", IBAN_CA833[0]) + datas, res = self._split("cfonb120_lf_multi_account_05.dat") + self.assertEqual(len(datas), 1) + self.assertEqual(len(res["notifications"]), 1) + self.assertEqual(res["notifications"][0]["type"], "error") + self.assertIn("20000000203", res["notifications"][0]["message"]) + + def test_no_line_terminator_mixed_empty_and_full(self): + """Modelled on LCL: 4 accounts, no terminator, empty and full mixed.""" + journals = [ + self._create_bank_journal(f"TLC{i}", iban) + for i, iban in enumerate(IBAN_LCL) + ] + datas, res = self._split("cfonb120_no_eol_multi_account_mixed.dat") + self.assertFalse(res["notifications"]) + self.assertEqual( + [d["journal_id"] for d in datas], [journals[1].id, journals[3].id] + ) + + def test_minimal_file_without_complement_lines(self): + """Modelled on Crédit Agricole 879: no 05 records, an account label in + the free zone of 01 and dashes in 07, three empty statements before + the only one with transactions.""" + journal = self._create_bank_journal("TCA9", IBAN_CA879[3]) + datas, res = self._split("cfonb120_lf_minimal_no_05.dat") + self.assertFalse(res["notifications"]) + self.assertEqual(len(datas), 1) + self.assertEqual(datas[0]["journal_id"], journal.id) + self.assertEqual( + [x[:2] for x in self._lines(datas[0])], ["01", "04", "04", "07"] + ) + + def test_rich_complement_lines_are_kept(self): + """Modelled on CIC: every 05 qualifier stays with its statement.""" + self._create_bank_journal("TCIC", IBAN_CIC) + datas, res = self._split("cfonb120_lf_rich_05.dat") + self.assertFalse(res["notifications"]) + self.assertEqual(len(datas), 1) + qualifiers = [x[45:48] for x in self._lines(datas[0]) if x[:2] == "05"] + self.assertEqual( + qualifiers, + ["NOM", "RIB", "IBE", "CBE", "LCC", "RCN", "REF", "NPY", "RUM", "LIB"], + ) + + def test_bad_length(self): + datas, res = self._split("cfonb120_bad_length.dat") + self.assertEqual(datas, []) + self.assertEqual(res["notifications"][0]["type"], "error") + self.assertIn("120", res["notifications"][0]["message"]) + + def test_journal_in_other_currency(self): + usd = self.env.ref("base.USD") + self._create_bank_journal("TCMB", IBAN_CMB, currency=usd) + datas, res = self._split("cfonb120_no_eol_single_account.dat") + self.assertEqual(datas, []) + self.assertEqual(res["notifications"][0]["type"], "error") + + +@tagged("post_install", "-at_install") +class TestSplitCamt(EbicsTestCommon): + def _split(self, sample): + ebics_file = self._create_ebics_file(sample, self.ff_camt053) + res = self._new_res() + return ebics_file._split_camt(res), res + + def _statements(self, st_data): + root = etree.fromstring(base64.b64decode(st_data["data"])) + ns = {"ns": root.nsmap[None]} + return root.xpath("//ns:Stmt/ns:Acct/ns:Id//text()", namespaces=ns) + + def test_single_statement_clav_fx(self): + """Modelled on UniCredit HVB: OPBD/CLBD/CLAV balances, FX entry.""" + journal = self._create_bank_journal("THVB", IBAN_HVB) + datas, res = self._split("camt053_hvb_clav_fx.xml") + self.assertFalse(res["notifications"]) + self.assertEqual(len(datas), 1) + self.assertEqual(datas[0]["journal_id"], journal.id) + self.assertEqual(datas[0]["acc_number"], IBAN_HVB) + + def test_multi_account_is_split(self): + j1 = self._create_bank_journal("TMU1", IBAN_MULTI[0]) + j2 = self._create_bank_journal("TMU2", IBAN_MULTI[1]) + datas, res = self._split("camt053_multi_account.xml") + self.assertFalse(res["notifications"]) + self.assertEqual([d["journal_id"] for d in datas], [j1.id, j2.id]) + self.assertEqual(self._statements(datas[0]), [IBAN_MULTI[0]]) + self.assertEqual(self._statements(datas[1]), [IBAN_MULTI[1]]) + + def test_currency_suffix_is_stripped(self): + """Built from the code: some banks (e.g. Commerzbank) append the + currency to the account number.""" + journal = self._create_bank_journal("TSUF", IBAN_SUFFIX) + datas, res = self._split("camt053_currency_suffix.xml") + self.assertFalse(res["notifications"]) + self.assertEqual(datas[0]["acc_number"], "0010000005") + self.assertEqual(datas[0]["journal_id"], journal.id) + + def test_statement_without_entries_is_skipped(self): + self._create_bank_journal("TSPK", IBAN_SPK) + datas, res = self._split("camt053_no_entries.xml") + self.assertEqual(datas, []) + self.assertFalse(res["notifications"]) + + def test_statement_without_account(self): + datas, res = self._split("camt053_no_account.xml") + self.assertEqual(datas, []) + self.assertEqual(res["notifications"][0]["type"], "error") + self.assertIn( + "No bank account number found", res["notifications"][0]["message"] + ) + + def test_invalid_xml(self): + datas, res = self._split("invalid.xml") + self.assertEqual(datas, []) + self.assertEqual(res["notifications"][0]["type"], "error") + self.assertIn("Invalid XML", res["notifications"][0]["message"]) + + +@tagged("post_install", "-at_install") +class TestLookupJournal(EbicsTestCommon): + def setUp(self): + super().setUp() + self.ebics_file = self._create_ebics_file( + "camt053_sparkasse.xml", self.ff_camt053 + ) + self.res = self._new_res() + + def test_unknown_currency(self): + currency, journal = self.ebics_file._lookup_journal( + self.res, "0010000002", "XYZ" + ) + self.assertFalse(currency) + self.assertFalse(journal) + self.assertIn("XYZ", self.res["notifications"][0]["message"]) + + def test_no_journal(self): + currency, journal = self.ebics_file._lookup_journal( + self.res, "0010000002", "EUR" + ) + self.assertEqual(currency, self.eur) + self.assertFalse(journal) + self.assertEqual(self.res["notifications"][0]["type"], "error") + + def test_journal_currency_match(self): + expected = self._create_bank_journal("TSPK", IBAN_SPK) + currency, journal = self.ebics_file._lookup_journal( + self.res, "0010000002", "eur" + ) + self.assertEqual(currency, self.eur) + self.assertEqual(journal, expected) + self.assertFalse(self.res["notifications"]) + + def test_journal_without_currency_uses_company_currency(self): + expected = self._create_bank_journal("TSPK", IBAN_SPK) + expected.sudo().currency_id = False + code = self.company.currency_id.name + currency, journal = self.ebics_file._lookup_journal( + self.res, "0010000002", code + ) + self.assertEqual(currency, self.company.currency_id) + self.assertEqual(journal, expected) diff --git a/account_ebics/tests/test_ebics_userid.py b/account_ebics/tests/test_ebics_userid.py new file mode 100644 index 0000000..a1a9c2a --- /dev/null +++ b/account_ebics/tests/test_ebics_userid.py @@ -0,0 +1,284 @@ +# Copyright 2026 Noviat. +# License LGPL-3 or later (https://www.gnu.org/licenses/lgpl). + +import base64 +import gc +import itertools +import os +import shutil +import tempfile +from unittest.mock import patch +from urllib.error import URLError + +from fintech.ebics import EbicsFunctionalError, EbicsKeyRing, EbicsTechnicalError + +from odoo import Command, fields +from odoo.exceptions import UserError +from odoo.tests import tagged +from odoo.tools import mute_logger + +from odoo.addons.account_ebics.models import ebics_userid as ebics_userid_module + +from .common import EbicsTestCommon + +# Throwaway passphrase for keys created in a temporary directory. +WRONG_PHRASE = "wrong-passphrase" +PUBLIC_BANK_KEYS = ( + "Public bank keys (simulated)\nX002 hash: 00 11 22\nE002 hash: 33 44 55\n" +) + + +def fake_init_client(hev=None, ini_error=None, hpb_error=None): + """Return a stand-in for fintech's EbicsClient covering the bank side of + the key ceremony. Keys, certificates and the INI letter stay real.""" + + class FakeInitClient: + calls = [] + + def __init__(self, bank, user, version=None): + self.bank = bank + + def HEV(self): + self.calls.append("HEV") + return hev if hev is not None else {"H004": "2.5", "H005": "3.0"} + + def _order(self, name): + self.calls.append(name) + return self.bank._next_order_id("PARTNER1") + + def INI(self): + if ini_error: + raise ini_error + return self._order("INI") + + def HIA(self): + return self._order("HIA") + + def HPB(self): + if hpb_error: + raise hpb_error + self.calls.append("HPB") + return PUBLIC_BANK_KEYS + + return FakeInitClient + + +class EbicsKeysCommon(EbicsTestCommon): + """Real keys in a temporary directory, simulated bank.""" + + _userid_seq = itertools.count(1) + + @classmethod + def setUpClass(cls): + super().setUpClass() + keys_dir = tempfile.mkdtemp(prefix="ebics_keys_test_") + cls.addClassCleanup(shutil.rmtree, keys_dir, ignore_errors=True) + cls.ebics_config.write({"ebics_keys": keys_dir, "ebics_key_bitlength": 2048}) + cls.bank_fr = cls.env["res.bank"].create( + {"name": "Banque Test", "country": cls.env.ref("base.fr").id} + ) + cls.journal = cls._create_bank_journal("TKEY", "FR7630004000031234567890143") + cls.journal.bank_account_id.sudo().bank_id = cls.bank_fr + + def _new_userid(self, **vals): + # A new name per test: fintech keeps open keyrings per file in-process. + return self.env["ebics.userid"].create( + { + "name": f"KEYUSER{next(self._userid_seq)}", + "ebics_config_id": self.ebics_config.id, + "user_ids": [Command.set(self.env.user.ids)], + "company_ids": [Command.set(self.company.ids)], + "ebics_passphrase": "init-passphrase", + **vals, + } + ) + + def _init_1(self, userid, client_class=None): + client_class = client_class or fake_init_client() + with patch.object(ebics_userid_module, "EbicsClient", client_class): + userid.ebics_init_1() + return client_class + + +@tagged("post_install", "-at_install") +class TestKeyCeremony(EbicsKeysCommon): + def test_full_ceremony(self): + userid = self._new_userid(ebics_key_x509=True, ebics_key_x509_dn_cn="Test") + client_class = self._init_1(userid) + self.assertEqual(client_class.calls, ["HEV", "INI", "HIA"]) + self.assertEqual(userid.state, "init") + self.assertTrue(os.path.isfile(userid.ebics_keys_fn)) + self.assertTrue(userid.ebics_keys_found) + self.assertTrue(base64.b64decode(userid.ebics_ini_letter).startswith(b"%PDF")) + today = fields.Date.today().isoformat() + self.assertEqual(userid.ebics_ini_letter_fn, f"TESTHOST_ini_letter_{today}.pdf") + + userid.ebics_init_2() + self.assertEqual(userid.state, "get_bank_keys") + + with patch.object(ebics_userid_module, "EbicsClient", client_class): + userid.ebics_init_3() + self.assertEqual(userid.state, "to_verify") + self.assertEqual( + base64.b64decode(userid.ebics_public_bank_keys).decode(), PUBLIC_BANK_KEYS + ) + self.assertEqual( + userid.ebics_public_bank_keys_fn, f"TESTHOST_public_bank_keys_{today}.txt" + ) + + bank_class = ebics_userid_module.EbicsBank + with patch.object(bank_class, "activate_keys") as activate_keys: + userid.ebics_init_4() + activate_keys.assert_called_once() + self.assertEqual(userid.state, "active_keys") + # stored passphrase is kept + self.assertEqual(userid.ebics_passphrase, "init-passphrase") + + def test_passphrase_not_stored_is_cleared(self): + userid = self._new_userid(ebics_passphrase_store=False) + self._init_1(userid) + self.assertFalse(userid.ebics_passphrase) + + def test_existing_keys_are_kept(self): + """A second init reuses the key file: reset tools must delete it.""" + userid = self._new_userid() + self._init_1(userid) + with open(userid.ebics_keys_fn, "rb") as f: + keys = f.read() + userid.set_to_draft() + self._init_1(userid) + with open(userid.ebics_keys_fn, "rb") as f: + self.assertEqual(f.read(), keys) + + def test_step_guards(self): + userid = self._new_userid() + userid.state = "init" + with self.assertRaisesRegex(UserError, "draft"): + userid.ebics_init_1() + userid.state = "draft" + for method in ("ebics_init_2", "ebics_init_3", "ebics_init_4"): + with self.subTest(method=method), self.assertRaises(UserError): + getattr(userid, method)() + + def test_init_requires_passphrase(self): + userid = self._new_userid(ebics_passphrase=False) + with self.assertRaisesRegex(UserError, "passphrase"): + userid.ebics_init_1() + + def test_3skey_requires_certificate(self): + userid = self._new_userid(swift_3skey=True) + with self.assertRaisesRegex(UserError, "3SKey certificate"): + userid.ebics_init_1() + + def test_init_requires_a_journal_with_bank(self): + self.ebics_config.journal_ids = False + userid = self._new_userid() + with self.assertRaisesRegex(UserError, "No bank defined"): + self._init_1(userid) + + def test_version_not_supported_by_bank(self): + userid = self._new_userid() + with self.assertRaisesRegex(UserError, "version mismatch"): + self._init_1(userid, fake_init_client(hev={"H003": "2.4"})) + self.assertEqual(userid.state, "draft") + + def test_init_errors(self): + cases = [ + (URLError("connection refused"), "urlopen error"), + ( + EbicsFunctionalError("091002", "EBICS_INVALID_USER_OR_USER_STATE"), + "091002", + ), + (EbicsTechnicalError("061001", "EBICS_AUTHENTICATION_FAILED"), "061001"), + ] + for error, expected in cases: + with self.subTest(error=type(error).__name__): + userid = self._new_userid() + with ( + mute_logger(ebics_userid_module.__name__), + self.assertRaisesRegex(UserError, expected), + ): + self._init_1(userid, fake_init_client(ini_error=error)) + + def test_get_bank_keys_error(self): + userid = self._new_userid() + self._init_1(userid) + userid.ebics_init_2() + error = EbicsFunctionalError("091002", "EBICS_INVALID_USER_OR_USER_STATE") + client_class = fake_init_client(hpb_error=error) + with ( + patch.object(ebics_userid_module, "EbicsClient", client_class), + self.assertRaisesRegex(UserError, "EBICS Functional Error"), + ): + userid.ebics_init_3() + + def test_h003_order_numbers(self): + self.ebics_config.write({"ebics_version": "H003", "order_number": "A000"}) + userid = self._new_userid() + self._init_1(userid, fake_init_client(hev={"H003": "2.4"})) + # INI uses A000, HIA uses A001 + self.assertEqual(self.ebics_config.order_number, "A002") + + def test_ini_letter_language(self): + calls = [] + + class SpyUser(ebics_userid_module.EbicsUser): + def create_ini_letter(self, bankname, lang=None, **kwargs): + calls.append((bankname, lang)) + return b"%PDF-1.4 spy" + + cases = [ + ("base.fr", "fr_FR", "fr"), + ("base.de", "fr_FR", "de"), + ("base.be", "fr_FR", "fr"), + ("base.be", "en_US", "en"), + ] + for country, user_lang, expected in cases: + with self.subTest(country=country, user_lang=user_lang): + lang = ( + self.env["res.lang"] + .sudo() + .with_context(active_test=False) + .search([("code", "=", user_lang)]) + ) + lang.active = True + self.bank_fr.country = self.env.ref(country) + self.env.user.lang = user_lang + userid = self._new_userid() + with patch.object(ebics_userid_module, "EbicsUser", SpyUser): + self._init_1(userid) + self.assertEqual(calls[-1], ("Banque Test", expected)) + + def test_key_renewal_requires_draft_config(self): + userid = self._new_userid() + with self.assertRaisesRegex(UserError, "Key Renewal"): + userid.set_to_get_bank_keys() + self.ebics_config.set_to_draft() + userid.set_to_get_bank_keys() + self.assertEqual(userid.state, "get_bank_keys") + + def test_h005_requires_x509(self): + self.ebics_config.ebics_version = "H005" + with self.assertRaisesRegex(UserError, "X.509"): + self._new_userid(ebics_key_x509=False) + + def test_stored_passphrase_is_checked(self): + userid = self._new_userid() + self._init_1(userid) + gc.collect() # no other keyring instance: fintech raises ValueError + userid.ebics_passphrase = WRONG_PHRASE + with self.assertRaisesRegex(UserError, "Passphrase mismatch"): + userid._onchange_ebics_passphrase_store() + + def test_stored_passphrase_is_checked_with_open_keyring(self): + """In a worker the keyring may already be open with the right + passphrase: fintech then raises RuntimeError.""" + userid = self._new_userid() + self._init_1(userid) + open_keyring = EbicsKeyRing( + keys=userid.ebics_keys_fn, passphrase=userid.ebics_passphrase + ) + userid.ebics_passphrase = WRONG_PHRASE + with self.assertRaisesRegex(UserError, "Passphrase mismatch"): + userid._onchange_ebics_passphrase_store() + del open_keyring diff --git a/account_ebics/tests/test_ebics_xfer.py b/account_ebics/tests/test_ebics_xfer.py new file mode 100644 index 0000000..f27b561 --- /dev/null +++ b/account_ebics/tests/test_ebics_xfer.py @@ -0,0 +1,245 @@ +# Copyright 2026 Noviat. +# License LGPL-3 or later (https://www.gnu.org/licenses/lgpl). + +import base64 +from datetime import date + +from fintech.ebics import ( + EbicsFunctionalError, + EbicsTechnicalError, + EbicsVerificationError, +) + +from odoo.tests import tagged + +from .common import EbicsTestCommon, FakeEbicsClient, read_sample + + +@tagged("post_install", "-at_install") +class TestEbicsXferDownload(EbicsTestCommon): + def test_line_terminator(self): + xfer = self.env["ebics.xfer"] + line_a, line_b = b"A" * 120, b"B" * 120 + self.assertEqual( + xfer._handle_cfonb120(line_a + line_b), line_a + b"\n" + line_b + b"\n" + ) + self.assertEqual( + xfer._handle_cfonb120(line_a + b"\r\n" + line_b + b"\r\n"), + line_a + b"\n" + line_b + b"\n", + ) + self.assertEqual( + xfer._handle_cfonb240(line_a + line_b), line_a + line_b + b"\n" + ) + # Current behaviour: an incomplete trailing line is dropped. + self.assertEqual(xfer._handle_cfonb120(line_a + b"C" * 10), line_a + b"\n") + + def test_download_fdl_cfonb(self): + data = read_sample("cfonb120_no_eol_single_account.dat") + wizard = self._download_wizard(self.ff_cfonb120, date_to=date(2026, 10, 13)) + with self._fake_client(FakeEbicsClient(downloads={"FDL": data})) as client: + action = wizard.ebics_download() + self.assertEqual(action["context"]["err_cnt"], 0) + ebics_file = self.env["ebics.file"].browse(action["context"]["ebics_file_ids"]) + self.assertEqual(ebics_file.name, "TESTHOST_PARTNER1_2026-10-13.cfonb120.dat") + self.assertEqual(ebics_file.state, "draft") + self.assertEqual(ebics_file.ebics_userid_id, self.ebics_userid) + self.assertEqual(ebics_file.company_ids, self.ebics_config.company_ids) + # FDL CFONB downloads get a line terminator every 120 characters + stored = base64.b64decode(ebics_file.data) + self.assertEqual(stored.count(b"\n"), 7) + self.assertEqual(client.confirmed, [("TRANS0001", True)]) + self.assertIn("is available for further processing", wizard.note) + + def test_download_btd_cfonb(self): + data = read_sample("cfonb120_no_eol_single_account.dat") + wizard = self._download_wizard(self.ff_btd_cfonb120, date_to=date(2026, 10, 13)) + with self._fake_client(FakeEbicsClient(downloads={"BTD": data})): + action = wizard.ebics_download() + ebics_file = self.env["ebics.file"].browse(action["context"]["ebics_file_ids"]) + self.assertEqual(len(ebics_file), 1) + # BTD formats have no name, hence no line terminator handler. + self.assertEqual(base64.b64decode(ebics_file.data), data) + + def test_same_name_in_another_format(self): + """FDL and BTD CFONB formats share the file suffix: same file name, + but a different format is not a duplicate.""" + data = read_sample("cfonb120_no_eol_single_account.dat") + names = [] + for file_format, order_type in ( + (self.ff_cfonb120, "FDL"), + (self.ff_btd_cfonb120, "BTD"), + ): + wizard = self._download_wizard(file_format, date_to=date(2026, 10, 13)) + client = FakeEbicsClient(downloads={order_type: data}) + with self._fake_client(client): + action = wizard.ebics_download() + self.assertEqual(action["context"]["err_cnt"], 0) + ebics_file = self.env["ebics.file"].browse( + action["context"]["ebics_file_ids"] + ) + names.append(ebics_file.name) + self.assertEqual(names[0], names[1]) + + def test_download_same_file_twice(self): + data = read_sample("cfonb120_no_eol_single_account.dat") + for expected_errors in (0, 1): + wizard = self._download_wizard(self.ff_cfonb120, date_to=date(2026, 10, 13)) + with self._fake_client(FakeEbicsClient(downloads={"FDL": data})) as client: + action = wizard.ebics_download() + self.assertEqual(action["context"]["err_cnt"], expected_errors) + self.assertIn("has already been downloaded", wizard.note) + self.assertEqual(client.confirmed, [("TRANS0001", False)]) + + def test_download_several_documents(self): + ptk = self.env["ebics.file.format"].create( + {"name": "PTK", "type": "down", "order_type": "PTK", "suffix": "txt"} + ) + data = read_sample("ptk_protocol.txt") + wizard = self._download_wizard(ptk) + client = FakeEbicsClient(downloads={"PTK": {"doc1": data, "doc2": data}}) + with self._fake_client(client): + action = wizard.ebics_download() + ebics_files = self.env["ebics.file"].browse(action["context"]["ebics_file_ids"]) + self.assertEqual( + sorted(ebics_files.mapped("name")), + ["TESTHOST_PARTNER1_doc1.txt", "TESTHOST_PARTNER1_doc2.txt"], + ) + # no processing method: the files are done right away + self.assertEqual(set(ebics_files.mapped("state")), {"done"}) + + def test_mark_as_downloaded(self): + data = read_sample("cfonb120_no_eol_single_account.dat") + wizard = self._download_wizard(self.ff_cfonb120).with_context( + ebics_mark_as_downloaded=True + ) + with self._fake_client(FakeEbicsClient(downloads={"FDL": data})) as client: + action = wizard.ebics_download() + self.assertFalse(action["context"]["ebics_file_ids"]) + self.assertEqual(client.confirmed, [("TRANS0001", True)]) + + def test_download_errors(self): + cases = [ + ( + EbicsFunctionalError("091005", "EBICS_NO_DOWNLOAD_DATA_AVAILABLE"), + "EBICS Functional Error", + "EBICS_NO_DOWNLOAD_DATA_AVAILABLE (code: 091005)", + ), + ( + EbicsTechnicalError("061002", "EBICS_INVALID_REQUEST"), + "EBICS Technical Error", + "EBICS_INVALID_REQUEST (code: 061002)", + ), + ( + EbicsVerificationError(), + "EBICS Verification Error", + "could not be verified", + ), + (RuntimeError("socket closed"), "Unknown Error", "socket closed"), + ] + for error, title, detail in cases: + with self.subTest(error=type(error).__name__): + wizard = self._download_wizard(self.ff_cfonb120) + with self._fake_client(FakeEbicsClient(error=error)) as client: + action = wizard.ebics_download() + self.assertEqual(action["context"]["err_cnt"], 1) + self.assertFalse(action["context"]["ebics_file_ids"]) + self.assertIn(title, wizard.note) + self.assertIn(detail, wizard.note) + self.assertEqual(client.confirmed, [("TRANS0001", False)]) + + def test_client_setup_failure(self): + wizard = self._download_wizard(self.ff_cfonb120) + with self._fake_client(False): + action = wizard.ebics_download() + self.assertEqual(action["context"]["err_cnt"], 1) + self.assertIn("client setup failed", wizard.note) + + +@tagged("post_install", "-at_install") +class TestEbicsXferUpload(EbicsTestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ff_btu = cls.env["ebics.file.format"].create( + { + "type": "up", + "order_type": "BTU", + "btf_service": "SCT", + "btf_message": "pain.001", + "suffix": "xml", + } + ) + cls.ff_ful = cls.env.ref("account_ebics.ebics_ff_LCR") + bank = cls.env["res.bank"].create( + {"name": "Test Bank FR", "country": cls.env.ref("base.fr").id} + ) + journal = cls._create_bank_journal("TUPL", "FR7630004000031234567890143") + journal.bank_account_id.sudo().bank_id = bank + + def test_upload_btu(self): + wizard = self._upload_wizard(self.ff_btu, test_mode=True) + with self._fake_client(FakeEbicsClient(order_id="N042")) as client: + ebics_file = wizard._ebics_upload() + self.assertEqual(ebics_file.name, "pay.xml") + self.assertEqual(ebics_file.state, "done") + self.assertEqual(ebics_file.pain_state, "uploaded") + self.assertEqual(ebics_file.ebics_order_number, "N042") + self.assertEqual(client.uploads, [("BTU", b"", {"TEST": "TRUE"})]) + self.assertIn("OrderID N042", wizard.note) + # H004: the order number is managed by the bank + self.assertFalse(self.ebics_config.order_number) + + def test_upload_same_name_twice(self): + names = [] + for _i in range(3): + wizard = self._upload_wizard(self.ff_btu) + with self._fake_client(FakeEbicsClient()): + names.append(wizard._ebics_upload().name) + # Pinned, probably unintended: _update_ef_vals appends each counter to + # the previous candidate (pay.xml_1_2) instead of pay.xml_2. + self.assertEqual(names, ["pay.xml", "pay.xml_1", "pay.xml_1_2"]) + + def test_upload_ful_h003_order_number(self): + self.ebics_config.write({"ebics_version": "H003", "order_number": "A00Z"}) + wizard = self._upload_wizard(self.ff_ful, fname="lcr.txt") + with self._fake_client(FakeEbicsClient(order_id="A00Z")) as client: + ebics_file = wizard._ebics_upload() + self.assertEqual(ebics_file.ebics_order_number, "A00Z") + self.assertEqual(client.uploads[0][0], "FUL") + self.assertEqual(client.uploads[0][2], {"country": "FR"}) + self.assertEqual(self.ebics_config.order_number, "A010") + + def test_upload_h003_failure_still_increments(self): + """Pinned behaviour: the order number moves on even if the upload failed.""" + self.ebics_config.write({"ebics_version": "H003", "order_number": "B000"}) + wizard = self._upload_wizard(self.ff_ful, fname="lcr.txt") + error = EbicsFunctionalError("091112", "EBICS_INVALID_ORDER_PARAMS") + with self._fake_client(FakeEbicsClient(error=error)): + ebics_file = wizard._ebics_upload() + self.assertFalse(ebics_file) + self.assertEqual(self.ebics_config.order_number, "B001") + self.assertIn("EBICS Functional Error", wizard.note) + self.assertIn("EBICS_INVALID_ORDER_PARAMS (code: 091112)", wizard.note) + + def test_upload_errors(self): + cases = [ + (EbicsTechnicalError("061001", "EBICS_AUTHENTICATION_FAILED"), "Technical"), + (EbicsVerificationError(), "Verification"), + (RuntimeError("socket closed"), "Unknown Error"), + ] + for error, expected in cases: + with self.subTest(error=type(error).__name__): + wizard = self._upload_wizard(self.ff_btu) + with self._fake_client(FakeEbicsClient(error=error)): + self.assertFalse(wizard._ebics_upload()) + self.assertIn(expected, wizard.note) + + def test_upload_onchange_selects_format(self): + """A single upload format on the config is selected automatically.""" + self.ebics_config.ebics_file_format_ids = self.ff_btu + wizard = self.env["ebics.xfer"].new( + {"ebics_config_id": self.ebics_config.id, "upload_fname": "pay.xml"} + ) + wizard._onchange_ebics_config_id() + self.assertEqual(wizard.format_id, self.ff_btu) + self.assertEqual(wizard.ebics_userid_id, self.ebics_userid) diff --git a/account_ebics/tests/test_ebics_xfer_client.py b/account_ebics/tests/test_ebics_xfer_client.py new file mode 100644 index 0000000..3aaa625 --- /dev/null +++ b/account_ebics/tests/test_ebics_xfer_client.py @@ -0,0 +1,115 @@ +# Copyright 2026 Noviat. +# License LGPL-3 or later (https://www.gnu.org/licenses/lgpl). + +import base64 +from unittest.mock import patch + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from odoo.addons.account_ebics.wizards import ebics_xfer as ebics_xfer_module + +from .test_ebics_userid import EbicsKeysCommon + +# Throwaway passphrase for keys created in a temporary directory. +WRONG_PHRASE = "wrong-passphrase" + + +def capturing_client(captured, init_error=None): + """Stand-in for fintech's EbicsClient that records the real EbicsBank and + EbicsUser built by ebics.xfer._setup_client.""" + + class CapturingClient: + def __init__(self, bank, user, version=None): + if init_error: + raise init_error + captured.update(bank=bank, user=user, version=version) + self.bank = bank + + def FUL(self, filetype, data, **kwargs): + # As fintech does on H003: the OrderID comes from the bank object. + return self.bank._next_order_id(captured["user"].partnerid) + + return CapturingClient + + +@tagged("post_install", "-at_install") +class TestSetupClient(EbicsKeysCommon): + """ebics.xfer._setup_client with real keys; only the transport is faked.""" + + def setUp(self): + super().setUp() + self.userid = self._new_userid() + self._init_1(self.userid) + self.userid.state = "active_keys" + self.ff_ful = self.env.ref("account_ebics.ebics_ff_LCR") + + def _wizard(self, file_format=None, **vals): + return self.env["ebics.xfer"].create( + { + "ebics_config_id": self.ebics_config.id, + "ebics_userid_id": self.userid.id, + "format_id": (file_format or self.ff_ful).id, + "upload_data": base64.b64encode(b"LCR DATA"), + "upload_fname": "lcr.txt", + "note": "", + **vals, + } + ) + + def _setup(self, wizard, init_error=None): + captured = {} + client_class = capturing_client(captured, init_error=init_error) + with patch.object(ebics_xfer_module, "EbicsClient", client_class): + client = wizard._setup_client() + return client, captured + + def test_client_from_config_and_keys(self): + client, captured = self._setup(self._wizard()) + self.assertTrue(client) + self.assertEqual(captured["version"], "H004") + self.assertEqual(captured["bank"].hostid, "TESTHOST") + self.assertEqual(captured["bank"].url, "https://ebics.example.invalid/ebics") + self.assertEqual(captured["user"].partnerid, "PARTNER1") + self.assertEqual(captured["user"].userid, self.userid.name) + + def test_signature_class(self): + cases = [ + # (userid default, file format override, expected transport_only) + ("T", False, True), + ("E", False, False), + ("T", "E", False), + ("E", "T", True), + ] + for user_class, format_class, expected in cases: + with self.subTest(user=user_class, file_format=format_class): + self.userid.signature_class = user_class + self.ff_ful.signature_class = format_class + _client, captured = self._setup(self._wizard()) + self.assertEqual(captured["user"].transport_only, expected) + + def test_h003_order_number_is_given_to_the_bank(self): + self.ebics_config.write({"ebics_version": "H003", "order_number": "B00Z"}) + wizard = self._wizard() + captured = {} + with patch.object(ebics_xfer_module, "EbicsClient", capturing_client(captured)): + ebics_file = wizard._ebics_upload() + self.assertEqual(ebics_file.ebics_order_number, "B00Z") + self.assertEqual(self.ebics_config.order_number, "B010") + + def test_wrong_passphrase(self): + wizard = self._wizard(ebics_passphrase=WRONG_PHRASE) + with self.assertRaisesRegex(UserError, "Error while accessing the EBICS Keys"): + self._setup(wizard) + + def test_missing_keys_directory(self): + self.ebics_config.ebics_keys = "/nonexistent/ebics_keys" + with self.assertRaisesRegex(UserError, "is not available"): + self._setup(self._wizard()) + + def test_client_creation_error(self): + wizard = self._wizard() + client, _captured = self._setup(wizard, init_error=RuntimeError("bad url")) + self.assertFalse(client) + self.assertIn("Unknown Error", wizard.note) + self.assertIn("bad url", wizard.note) diff --git a/account_ebics_batch/__manifest__.py b/account_ebics_batch/__manifest__.py index de8dbf3..4e290be 100644 --- a/account_ebics_batch/__manifest__.py +++ b/account_ebics_batch/__manifest__.py @@ -3,7 +3,7 @@ { "name": "EBICS - Scheduled File Import", - "version": "18.0.1.0.2", + "version": "18.0.1.0.3", "license": "AGPL-3", "author": "Noviat", "website": "https://www.noviat.com/", diff --git a/account_ebics_batch/tests/__init__.py b/account_ebics_batch/tests/__init__.py new file mode 100644 index 0000000..1b196b9 --- /dev/null +++ b/account_ebics_batch/tests/__init__.py @@ -0,0 +1 @@ +from . import test_ebics_batch_import diff --git a/account_ebics_batch/tests/test_ebics_batch_import.py b/account_ebics_batch/tests/test_ebics_batch_import.py new file mode 100644 index 0000000..acb0613 --- /dev/null +++ b/account_ebics_batch/tests/test_ebics_batch_import.py @@ -0,0 +1,95 @@ +# Copyright 2026 Noviat. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from fintech.ebics import EbicsTechnicalError + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from odoo.addons.account_ebics.tests.common import ( + EbicsTestCommon, + FakeEbicsClient, + read_sample, +) + + +@tagged("post_install", "-at_install") +class TestEbicsBatchImport(EbicsTestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + # A format without processing method: files are stored as done. + cls.ff_ptk = cls.env["ebics.file.format"].create( + {"name": "PTK", "type": "down", "order_type": "PTK", "suffix": "txt"} + ) + cls.ebics_config.ebics_file_format_ids = cls.ff_ptk + cls.log_model = cls.env["ebics.batch.log"] + + def _run(self, client, **kwargs): + with self._fake_client(client): + self.log_model._batch_import( + ebics_config_ids=self.ebics_config.ids, **kwargs + ) + return self.log_model.search([], order="id desc", limit=1) + + def test_import(self): + client = FakeEbicsClient(downloads={"PTK": read_sample("ptk_protocol.txt")}) + log = self._run(client, date_from="2026-10-12", date_to="2026-10-13") + self.assertEqual(log.state, "done") + self.assertEqual(log.ebics_config_ids, self.ebics_config) + self.assertEqual(log.file_count, 1) + self.assertEqual(log.file_ids.name, "TESTHOST_PARTNER1_2026-10-13.txt") + self.assertEqual(log.log_ids.state, "done") + self.assertEqual(log.log_ids.error_count, 0) + + def test_import_prefers_transport_signature_user(self): + """Downloads need no signature right: a class T user is preferred.""" + self.ebics_userid.signature_class = "E" + transport_userid = self.ebics_userid.copy( + {"name": "USER2", "signature_class": "T", "state": "active_keys"} + ) + client = FakeEbicsClient(downloads={"PTK": read_sample("ptk_protocol.txt")}) + log = self._run(client, date_to="2026-10-13") + self.assertEqual(log.file_ids.ebics_userid_id, transport_userid) + + def test_import_transfer_error(self): + error = EbicsTechnicalError("061001", "EBICS_AUTHENTICATION_FAILED") + log = self._run(FakeEbicsClient(error=error)) + self.assertEqual(log.state, "error") + self.assertFalse(log.file_ids) + self.assertEqual(log.log_ids.error_count, 1) + self.assertIn("EBICS_AUTHENTICATION_FAILED", log.log_ids.note) + + def test_import_draft_config(self): + self.ebics_config.set_to_draft() + log = self._run(FakeEbicsClient()) + self.assertEqual(log.state, "error") + self.assertIn("Please set state to 'Confirm'", log.log_ids.note) + + def test_import_without_stored_passphrase(self): + self.ebics_userid.ebics_passphrase_store = False + log = self._run(FakeEbicsClient()) + self.assertEqual(log.state, "error") + self.assertIn("No EBICS UserID with stored passphrase", log.log_ids.note) + + def test_draft_files_are_processed_and_reprocessed(self): + ebics_file = self._create_ebics_file("camt053_sparkasse.xml", self.ff_camt053) + log = self.log_model.create({"file_ids": [(6, 0, ebics_file.ids)]}) + self.assertTrue(log.has_draft_files) + file_class = type(ebics_file) + with patch.object(file_class, "process", autospec=True) as process: + log.reprocess() + process.assert_called_once_with(ebics_file) + # the file is still draft since processing was mocked + self.assertEqual(log.state, "draft") + + def test_unlink_only_draft_logs(self): + log = self.log_model.create({}) + log.button_done() + with self.assertRaises(UserError): + log.unlink() + log.button_draft() + log.unlink() + self.assertFalse(log.exists()) diff --git a/account_ebics_batch_payment/__manifest__.py b/account_ebics_batch_payment/__manifest__.py index ba704c6..4b56b86 100644 --- a/account_ebics_batch_payment/__manifest__.py +++ b/account_ebics_batch_payment/__manifest__.py @@ -4,7 +4,7 @@ { "name": "EBICS - Batch Payments (Odoo Enterprise)", "summary": "Send an Odoo Enterprise batch payment to the bank over EBICS", - "version": "18.0.1.3.0", + "version": "18.0.1.3.1", "license": "LGPL-3", "author": "Noviat", "website": "https://www.noviat.com/", diff --git a/account_ebics_batch_payment/tests/__init__.py b/account_ebics_batch_payment/tests/__init__.py new file mode 100644 index 0000000..2684399 --- /dev/null +++ b/account_ebics_batch_payment/tests/__init__.py @@ -0,0 +1 @@ +from . import test_batch_payment_upload diff --git a/account_ebics_batch_payment/tests/test_batch_payment_upload.py b/account_ebics_batch_payment/tests/test_batch_payment_upload.py new file mode 100644 index 0000000..87cbd6d --- /dev/null +++ b/account_ebics_batch_payment/tests/test_batch_payment_upload.py @@ -0,0 +1,77 @@ +# Copyright 2026 Noviat. +# License LGPL-3 or later (https://www.gnu.org/licenses/lgpl). + +import base64 +from unittest.mock import patch + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from odoo.addons.account_ebics.tests.common import EbicsTestCommon, FakeEbicsClient + + +@tagged("post_install", "-at_install") +class TestBatchPaymentUpload(EbicsTestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.journal = cls._create_bank_journal("TBP", "FR7630004000031234567890143") + cls.ff_cct = cls.env.ref("account_ebics.ebics_ff_CCT") + cls.method = cls.env.ref("account.account_payment_method_manual_out") + + def _batch(self, state="sent"): + batch = self.env["account.batch.payment"].create( + { + "journal_id": self.journal.id, + "batch_type": "outbound", + "payment_method_id": self.method.id, + "export_file": base64.b64encode(b"pain.001"), + "export_filename": "batch.xml", + } + ) + # Without payments the batch stays in draft: set the state directly. + batch.state = state + return batch + + def _generating_files(self): + batch_class = type(self.env["account.batch.payment"]) + return patch.object( + batch_class, "_get_methods_generating_files", return_value=["manual"] + ) + + def test_hide_ebics_upload(self): + with self._generating_files(): + self.assertFalse(self._batch().hide_ebics_upload) + self.assertTrue(self._batch(state="draft").hide_ebics_upload) + batch = self._batch() + batch.ebics_file_id = self.env["ebics.file"].create( + {"name": "batch.xml", "date": "2026-10-13", "pain_state": "uploaded"} + ) + self.assertTrue(batch.hide_ebics_upload) + # no file generation for this payment method + self.assertTrue(self._batch().hide_ebics_upload) + + def test_upload_requires_ebics_config(self): + self.ebics_config.journal_ids = False + with self.assertRaises(UserError): + self._batch().ebics_upload() + + def test_upload_format_from_payment_method(self): + self.method.ebics_file_format_id = self.ff_cct + action = self._batch().ebics_upload() + wizard = self.env["ebics.xfer"].browse(action["res_id"]) + self.assertEqual(wizard.format_id, self.ff_cct) + self.assertEqual(wizard.upload_fname, "batch.xml") + + def test_upload(self): + self.method.ebics_file_format_id = self.ff_cct + batch = self._batch() + action = batch.ebics_upload() + wizard = self.env["ebics.xfer"].browse(action["res_id"]) + with self._fake_client(FakeEbicsClient(order_id="N088")) as client: + wizard.with_context(**action["context"]).ebics_upload() + self.assertEqual(client.uploads[0][1], b"pain.001") + self.assertEqual(batch.ebics_order_number, "N088") + self.assertEqual(batch.ebics_pain_state, "uploaded") + self.assertIn(f"Batch Payment: {batch.name}", batch.ebics_file_id.note) + self.assertTrue(any("N088" in msg.body for msg in batch.message_ids)) diff --git a/account_ebics_oe_statement_import_test/README.rst b/account_ebics_oe_statement_import_test/README.rst new file mode 100644 index 0000000..fc3d5a2 --- /dev/null +++ b/account_ebics_oe_statement_import_test/README.rst @@ -0,0 +1,15 @@ +========================================= +EBICS - Enterprise Statement Import Tests +========================================= + +**Test-only module. Do not install it on a production database.** + +It carries no code and no data. It exists so that continuous integration +installs the Odoo Enterprise CAMT parser (``account_bank_statement_import_camt``) +next to ``account_ebics`` and runs the statement import tests through the +Enterprise import. It is excluded from the Community test job. + +Credits +======= + +Developed and maintained by `Noviat `_. diff --git a/account_ebics_oe_statement_import_test/__init__.py b/account_ebics_oe_statement_import_test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/account_ebics_oe_statement_import_test/__manifest__.py b/account_ebics_oe_statement_import_test/__manifest__.py new file mode 100644 index 0000000..e907c00 --- /dev/null +++ b/account_ebics_oe_statement_import_test/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2026 Noviat. +# License LGPL-3 or later (https://www.gnu.org/licenses/lgpl). + +{ + "name": "EBICS - Enterprise Statement Import Tests", + "summary": "Test-only module, do not install: runs the EBICS statement " + "import tests against the Odoo Enterprise CAMT parser", + "version": "18.0.1.0.0", + "license": "LGPL-3", + "author": "Noviat", + "website": "https://www.noviat.com/", + "category": "Hidden", + "depends": [ + "account_ebics", + "account_bank_statement_import_camt", + ], +} diff --git a/account_ebics_oe_statement_import_test/pyproject.toml b/account_ebics_oe_statement_import_test/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/account_ebics_oe_statement_import_test/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/account_ebics_oe_statement_import_test/static/description/icon.png b/account_ebics_oe_statement_import_test/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a2bfe0319365384abfabc65e1a9e784e9842ce74 GIT binary patch literal 19480 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 + split per account -> ir.attachment -> journal._import_bank_statement.""" + + def _enterprise_parser_only(self): + """Both camt parsers are installed in the Enterprise CI job and + _process_camt053 prefers the OCA one: hide it.""" + file_class = type(self.env["ebics.file"]) + check_import_module = file_class._check_import_module + + def check(record, module, raise_if_not_found=True): + if module == "account_statement_import_camt": + return False + return check_import_module(record, module, raise_if_not_found) + + return patch.object(file_class, "_check_import_module", check) + + def _process(self, sample, name=None): + ebics_file = self._create_ebics_file(sample, self.ff_camt053, name=name) + with self._enterprise_parser_only(): + ebics_file.process() + self.assertEqual(ebics_file.state, "done") + return ebics_file + + def test_camt_statement(self): + journal = self._create_bank_journal("THVB", IBAN_HVB) + ebics_file = self._process("camt053_hvb_clav_fx.xml") + statement = ebics_file.bank_statement_ids + self.assertEqual(statement.journal_id, journal) + self.assertEqual(statement.balance_start, 5000.0) + self.assertEqual(statement.balance_end_real, 3992.5) + self.assertEqual(sorted(statement.line_ids.mapped("amount")), [-857.5, -150.0]) + self.assertEqual(statement.import_format, "camt053") + self.assertIn("1 bank statement has been imported", ebics_file.note_process) + + def test_camt_multi_account(self): + j1 = self._create_bank_journal("TMU1", IBAN_MULTI[0]) + j2 = self._create_bank_journal("TMU2", IBAN_MULTI[1]) + ebics_file = self._process("camt053_multi_account.xml") + self.assertEqual(ebics_file.bank_statement_ids.journal_id, j1 | j2) + self.assertIn("2 bank statements have been imported", ebics_file.note_process) + + def test_reimport_is_reported(self): + self._create_bank_journal("THVB", IBAN_HVB) + sample = "camt053_hvb_clav_fx.xml" + first = self._process(sample, name="first.xml") + self.assertEqual(len(first.bank_statement_ids), 1) + second = self._process(sample, name="second.xml") + self.assertFalse(second.bank_statement_ids) + self.assertIn("has not been processed", second.note_process) + self.assertIn("0010000001", second.note_process) diff --git a/account_ebics_payment_order/__manifest__.py b/account_ebics_payment_order/__manifest__.py index 3c69646..afe6a83 100644 --- a/account_ebics_payment_order/__manifest__.py +++ b/account_ebics_payment_order/__manifest__.py @@ -4,7 +4,7 @@ { "name": "EBICS - Payment Orders (OCA)", "summary": "Send an OCA payment order to the bank over EBICS", - "version": "18.0.1.3.0", + "version": "18.0.1.3.1", "license": "LGPL-3", "author": "Noviat", "website": "https://www.noviat.com/", diff --git a/account_ebics_payment_order/tests/__init__.py b/account_ebics_payment_order/tests/__init__.py new file mode 100644 index 0000000..c99d810 --- /dev/null +++ b/account_ebics_payment_order/tests/__init__.py @@ -0,0 +1 @@ +from . import test_payment_order_upload diff --git a/account_ebics_payment_order/tests/test_payment_order_upload.py b/account_ebics_payment_order/tests/test_payment_order_upload.py new file mode 100644 index 0000000..07b0ca5 --- /dev/null +++ b/account_ebics_payment_order/tests/test_payment_order_upload.py @@ -0,0 +1,95 @@ +# Copyright 2026 Noviat. +# License LGPL-3 or later (https://www.gnu.org/licenses/lgpl). + +import base64 + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from odoo.addons.account_ebics.tests.common import EbicsTestCommon, FakeEbicsClient + + +@tagged("post_install", "-at_install") +class TestPaymentOrderUpload(EbicsTestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env.user.groups_id |= cls.env.ref( + "account_payment_order.group_account_payment" + ) + cls.journal = cls._create_bank_journal("TPO", "FR7630004000031234567890143") + cls.ff_cct = cls.env.ref("account_ebics.ebics_ff_CCT") + cls.mode = cls.env["account.payment.mode"].create( + { + "name": "EBICS credit transfer", + "company_id": cls.company.id, + "bank_account_link": "fixed", + "fixed_journal_id": cls.journal.id, + "payment_method_id": cls.env.ref( + "account.account_payment_method_manual_out" + ).id, + "ebics_format_id": cls.ff_cct.id, + } + ) + + def _order(self, state="generated", with_file=True): + order = self.env["account.payment.order"].create( + { + "payment_type": "outbound", + "payment_mode_id": self.mode.id, + "journal_id": self.journal.id, + } + ) + order.state = state + if with_file: + self._attach(order, "pain001.xml") + return order + + def _attach(self, order, name): + return self.env["ir.attachment"].create( + { + "name": name, + "res_model": order._name, + "res_id": order.id, + "datas": base64.b64encode(b"pain.001"), + } + ) + + def test_hide_ebics_upload(self): + self.assertTrue(self._order(state="draft").hide_ebics_upload) + self.assertFalse(self._order().hide_ebics_upload) + self.ebics_config.journal_ids = False + self.assertTrue(self._order().hide_ebics_upload) + + def test_upload_requires_format_on_mode(self): + self.mode.ebics_format_id = False + with self.assertRaisesRegex(UserError, "EBICS File Format"): + self._order().ebics_upload() + + def test_upload_requires_one_file(self): + with self.assertRaisesRegex(UserError, "doesn't contain attachments"): + self._order(with_file=False).ebics_upload() + order = self._order() + self._attach(order, "pain001_old.xml") + with self.assertRaisesRegex(UserError, "multiple attachments"): + order.ebics_upload() + + def test_upload(self): + order = self._order() + # the form button passes the active model, as the web client does + action = order.with_context( + active_model=order._name, active_id=order.id + ).ebics_upload() + wizard = self.env["ebics.xfer"].browse(action["res_id"]) + self.assertEqual(wizard.ebics_config_id, self.ebics_config) + self.assertEqual(wizard.ebics_userid_id, self.ebics_userid) + self.assertEqual(wizard.format_id, self.ff_cct) + self.assertEqual(wizard.upload_fname, "pain001.xml") + with self._fake_client(FakeEbicsClient(order_id="N077")) as client: + wizard.with_context(**action["context"]).ebics_upload() + self.assertEqual(client.uploads[0][1], b"pain.001") + self.assertEqual(order.state, "uploaded") + self.assertEqual(order.ebics_order_number, "N077") + self.assertEqual(order.ebics_file_id.pain_state, "uploaded") + self.assertIn(f"Payment Order: {order.name}", order.ebics_file_id.note) + self.assertTrue(any("N077" in msg.body for msg in order.message_ids)) diff --git a/account_ebics_statement_import_test/README.rst b/account_ebics_statement_import_test/README.rst new file mode 100644 index 0000000..b370d2c --- /dev/null +++ b/account_ebics_statement_import_test/README.rst @@ -0,0 +1,16 @@ +============================== +EBICS - Statement Import Tests +============================== + +**Test-only module. Do not install it on a production database.** + +It carries no code and no data. It exists so that continuous integration +installs the OCA statement parsers (``account_statement_import_camt`` and +``account_statement_import_fr_cfonb``) next to ``account_ebics`` and runs the +end-to-end statement import tests: EBICS download, split per account, bank +statement creation. + +Credits +======= + +Developed and maintained by `Noviat `_. diff --git a/account_ebics_statement_import_test/__init__.py b/account_ebics_statement_import_test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/account_ebics_statement_import_test/__manifest__.py b/account_ebics_statement_import_test/__manifest__.py new file mode 100644 index 0000000..5c5c447 --- /dev/null +++ b/account_ebics_statement_import_test/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2026 Noviat. +# License LGPL-3 or later (https://www.gnu.org/licenses/lgpl). + +{ + "name": "EBICS - Statement Import Tests", + "summary": "Test-only module, do not install: runs the EBICS statement " + "import tests against the OCA CAMT and CFONB parsers", + "version": "18.0.1.0.0", + "license": "LGPL-3", + "author": "Noviat", + "website": "https://www.noviat.com/", + "category": "Hidden", + "depends": [ + "account_ebics_oca_statement_import", + "account_statement_import_camt", + "account_statement_import_fr_cfonb", + ], +} diff --git a/account_ebics_statement_import_test/pyproject.toml b/account_ebics_statement_import_test/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/account_ebics_statement_import_test/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/account_ebics_statement_import_test/static/description/icon.png b/account_ebics_statement_import_test/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a2bfe0319365384abfabc65e1a9e784e9842ce74 GIT binary patch literal 19480 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 split per account -> OCA parser -> statements. + + These assertions hold whether or not the account_ebics_oca_statement_import + glue is installed (it does not exist on 19.0).""" + + def _process(self, sample, file_format, name=None): + ebics_file = self._create_ebics_file(sample, file_format, name=name) + ebics_file.process() + self.assertEqual(ebics_file.state, "done") + return ebics_file + + def test_cfonb_multi_account(self): + j1 = self._create_bank_journal("TCA1", IBAN_CA833[0]) + j3 = self._create_bank_journal("TCA3", IBAN_CA833[2]) + ebics_file = self._process("cfonb120_lf_multi_account_05.dat", self.ff_cfonb120) + statements = ebics_file.bank_statement_ids.sorted("journal_id") + self.assertEqual(statements.journal_id, j1 | j3) + st1 = statements.filtered(lambda s: s.journal_id == j1) + self.assertEqual(st1.balance_start, 8400.0) + self.assertAlmostEqual(st1.balance_end_real, 8907.45) + self.assertEqual(sorted(st1.line_ids.mapped("amount")), [-132.55, 640.0]) + self.assertIn("FACT 99812", " ".join(st1.line_ids.mapped("payment_ref"))) + self.assertEqual(set(statements.mapped("import_format")), {"cfonb120"}) + self.assertIn("2 bank statements have been imported", ebics_file.note_process) + + def test_cfonb_minimal_without_complement_lines(self): + journal = self._create_bank_journal("TCA9", IBAN_CA879[3]) + ebics_file = self._process("cfonb120_lf_minimal_no_05.dat", self.ff_cfonb120) + statement = ebics_file.bank_statement_ids + self.assertEqual(statement.journal_id, journal) + self.assertEqual(len(statement.line_ids), 2) + self.assertEqual(statement.balance_end_real, 5290.0) + + def test_cfonb_no_line_terminator(self): + journals = [ + self._create_bank_journal(f"TLC{i}", iban) + for i, iban in enumerate(IBAN_LCL) + ] + ebics_file = self._process( + "cfonb120_no_eol_multi_account_mixed.dat", self.ff_cfonb120 + ) + self.assertEqual( + ebics_file.bank_statement_ids.journal_id, journals[1] | journals[3] + ) + + def test_cfonb_complement_lines_in_label(self): + self._create_bank_journal("TCIC", IBAN_CIC) + ebics_file = self._process("cfonb120_lf_rich_05.dat", self.ff_cfonb120) + refs = " ".join(ebics_file.bank_statement_ids.line_ids.mapped("payment_ref")) + self.assertIn("FACTURE 7731", refs) + self.assertIn("SALE", refs) + + def test_cfonb_missing_journal_is_reported(self): + self._create_bank_journal("TCA1", IBAN_CA833[0]) + ebics_file = self._process("cfonb120_lf_multi_account_05.dat", self.ff_cfonb120) + self.assertEqual(len(ebics_file.bank_statement_ids), 1) + self.assertIn("No financial journal found", ebics_file.note_process) + self.assertIn("20000000203", ebics_file.note_process) + + def test_cfonb_empty_file(self): + self._create_bank_journal("TCMB", IBAN_CMB) + ebics_file = self._process("cfonb120_no_eol_empty_only.dat", self.ff_cfonb120) + self.assertFalse(ebics_file.bank_statement_ids) + self.assertIn("doesn't contain any transaction", ebics_file.note_process) + + def test_camt_fx_entry(self): + journal = self._create_bank_journal("THVB", IBAN_HVB) + ebics_file = self._process("camt053_hvb_clav_fx.xml", self.ff_camt053) + statement = ebics_file.bank_statement_ids + self.assertEqual(statement.journal_id, journal) + self.assertEqual(statement.balance_start, 5000.0) + self.assertEqual(statement.balance_end_real, 3992.5) + self.assertEqual(sorted(statement.line_ids.mapped("amount")), [-857.5, -150.0]) + self.assertEqual(statement.import_format, "camt053") + + def test_camt_multi_account(self): + j1 = self._create_bank_journal("TMU1", IBAN_MULTI[0]) + j2 = self._create_bank_journal("TMU2", IBAN_MULTI[1]) + ebics_file = self._process("camt053_multi_account.xml", self.ff_camt053) + self.assertEqual(ebics_file.bank_statement_ids.journal_id, j1 | j2) + + def test_download_then_process(self): + journal = self._create_bank_journal("TCMB", IBAN_CMB) + data = read_sample("cfonb120_no_eol_single_account.dat") + wizard = self._download_wizard(self.ff_cfonb120, date_to=date(2026, 10, 13)) + with self._fake_client(FakeEbicsClient(downloads={"FDL": data})): + action = wizard.ebics_download() + ebics_file = self.env["ebics.file"].browse(action["context"]["ebics_file_ids"]) + ebics_file.process() + self.assertEqual(ebics_file.bank_statement_ids.journal_id, journal) + self.assertEqual(len(ebics_file.bank_statement_ids.line_ids), 2) + self.assertEqual( + ebics_file.bank_statement_ids.ebics_file_id, + ebics_file, + ) + + +@tagged("post_install", "-at_install") +class TestStatementImportOcaGlue(EbicsTestCommon): + """Behaviour added by account_ebics_oca_statement_import (18.0 only). + + Drop this class when forward-porting to a version without that module.""" + + def test_reimport_is_reported(self): + self._create_bank_journal("TCMB", IBAN_CMB) + sample = "cfonb120_no_eol_single_account.dat" + first = self._create_ebics_file(sample, self.ff_cfonb120, name="first.dat") + first.process() + self.assertEqual(len(first.bank_statement_ids), 1) + second = self._create_ebics_file(sample, self.ff_cfonb120, name="second.dat") + second.process() + self.assertFalse(second.bank_statement_ids) + self.assertIn("has already been imported", second.note_process) + + def test_currency_suffix_on_account(self): + journal = self._create_bank_journal("TSUF", IBAN_SUFFIX) + ebics_file = self._create_ebics_file( + "camt053_currency_suffix.xml", self.ff_camt053 + ) + ebics_file.process() + self.assertEqual(ebics_file.bank_statement_ids.journal_id, journal) diff --git a/test-requirements.txt b/test-requirements.txt index d2fce6c..159fb3b 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,3 +1,5 @@ odoo-addon-module_change_auto_install openupgradelib lxml_html_clean +odoo-addon-account_statement_import_camt +odoo-addon-account_statement_import_fr_cfonb