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.00CRDT2026-10-13CLBD15.00CRDT2026-10-13SUFFIX-00015.00CRDTBOOK2026-10-132026-10-13SUFFIX-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.00CRDT2026-10-13CLBD3992.50CRDT2026-10-13CLAV3992.50CRDT2026-10-13HVB-0001150.00DBITBOOK2026-10-132026-10-13HVB-0001PMNTICDTESCTNTRF+116DKHVB-0001E2E-LAMBDA-118150.00Rechnung 2026-118 Lambda GmbHHVB-0002857.50DBITBOOK2026-10-132026-10-13HVB-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.00CRDT2026-10-13CLBD130.00CRDT2026-10-13MULTI-000130.00CRDTBOOK2026-10-132026-10-13MULTI-0001PMNTICDTESCTNTRF+116DKMULTI-000130.00Omikron paymentMULTI-STMT-212026-10-13T22:00:00DE42370400440010000004EUROPBD200.00CRDT2026-10-13CLBD180.00CRDT2026-10-13MULTI-000220.00DBITBOOK2026-10-132026-10-13MULTI-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.00CRDT2026-10-13CLBD2.00CRDT2026-10-13NOACC-00011.00CRDTBOOK2026-10-132026-10-13NOACC-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.00CRDT2026-10-13CLBD720.00CRDT2026-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.00CRDT2026-10-13CLBD1065.00CRDT2026-10-13SPK-0001400.00CRDTBOOK2026-10-132026-10-13SPK-0001PMNTICDTESCTNTRF+116DKSPK-0001400.00Gutschrift Nu KGSPK-000255.00DBITBOOK2026-10-132026-10-13SPK-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 0000000..a2bfe03
Binary files /dev/null and b/account_ebics_oe_statement_import_test/static/description/icon.png differ
diff --git a/account_ebics_oe_statement_import_test/tests/__init__.py b/account_ebics_oe_statement_import_test/tests/__init__.py
new file mode 100644
index 0000000..e1891ea
--- /dev/null
+++ b/account_ebics_oe_statement_import_test/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_oe_statement_import
diff --git a/account_ebics_oe_statement_import_test/tests/test_oe_statement_import.py b/account_ebics_oe_statement_import_test/tests/test_oe_statement_import.py
new file mode 100644
index 0000000..1209a1e
--- /dev/null
+++ b/account_ebics_oe_statement_import_test/tests/test_oe_statement_import.py
@@ -0,0 +1,66 @@
+# Copyright 2026 Noviat.
+# License LGPL-3 or later (https://www.gnu.org/licenses/lgpl).
+
+from unittest.mock import patch
+
+from odoo.tests import tagged
+
+from odoo.addons.account_ebics.tests.common import (
+ IBAN_HVB,
+ IBAN_MULTI,
+ EbicsTestCommon,
+)
+
+
+@tagged("post_install", "-at_install")
+class TestOeStatementImport(EbicsTestCommon):
+ """End to end through the Odoo Enterprise camt import: EBICS file ->
+ 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 0000000..a2bfe03
Binary files /dev/null and b/account_ebics_statement_import_test/static/description/icon.png differ
diff --git a/account_ebics_statement_import_test/tests/__init__.py b/account_ebics_statement_import_test/tests/__init__.py
new file mode 100644
index 0000000..4533a36
--- /dev/null
+++ b/account_ebics_statement_import_test/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_statement_import
diff --git a/account_ebics_statement_import_test/tests/test_statement_import.py b/account_ebics_statement_import_test/tests/test_statement_import.py
new file mode 100644
index 0000000..bb6e479
--- /dev/null
+++ b/account_ebics_statement_import_test/tests/test_statement_import.py
@@ -0,0 +1,145 @@
+# Copyright 2026 Noviat.
+# License LGPL-3 or later (https://www.gnu.org/licenses/lgpl).
+
+from datetime import date
+
+from odoo.tests import tagged
+
+from odoo.addons.account_ebics.tests.common import (
+ IBAN_CA833,
+ IBAN_CA879,
+ IBAN_CIC,
+ IBAN_CMB,
+ IBAN_HVB,
+ IBAN_LCL,
+ IBAN_MULTI,
+ IBAN_SUFFIX,
+ EbicsTestCommon,
+ FakeEbicsClient,
+ read_sample,
+)
+
+
+@tagged("post_install", "-at_install")
+class TestStatementImport(EbicsTestCommon):
+ """End to end: EBICS file -> 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