From 2505a6e337f4b8797c4fdc3518c499b0f90e5bc0 Mon Sep 17 00:00:00 2001 From: Pierre Verkest Date: Tue, 23 Jun 2026 22:37:56 +0200 Subject: [PATCH 1/2] [FIX] l10n_fr_einvoicing: avoid warning for duplicated field name --- l10n_fr_einvoicing/models/account_move.py | 4 +++- .../wizards/fr_einvoicing_event_manual.py | 10 ++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/l10n_fr_einvoicing/models/account_move.py b/l10n_fr_einvoicing/models/account_move.py index acf2d28..46e087a 100644 --- a/l10n_fr_einvoicing/models/account_move.py +++ b/l10n_fr_einvoicing/models/account_move.py @@ -49,7 +49,9 @@ class AccountMove(models.Model): readonly=False, string="Directory Line Identifier", ) - company_partner_id = fields.Many2one(related="company_id.partner_id") + company_partner_id = fields.Many2one( + related="company_id.partner_id", string="Partner company" + ) company_fr_directory_line_id = fields.Many2one( "fr.directory.line", compute="_compute_company_fr_directory_line_id", diff --git a/l10n_fr_einvoicing/wizards/fr_einvoicing_event_manual.py b/l10n_fr_einvoicing/wizards/fr_einvoicing_event_manual.py index fabc3f6..15349bc 100644 --- a/l10n_fr_einvoicing/wizards/fr_einvoicing_event_manual.py +++ b/l10n_fr_einvoicing/wizards/fr_einvoicing_event_manual.py @@ -152,12 +152,14 @@ class FrEinvoicingEventDetailManual(models.TransientModel): readonly=True, ondelete="cascade", ) - reason_dispute = fields.Selection("_reason_dispute_selection", string="Reason") + reason_dispute = fields.Selection( + "_reason_dispute_selection", string="Dispute reason" + ) reason_partially_approved = fields.Selection( - "_reason_partially_approved_selection", string="Reason" + "_reason_partially_approved_selection", string="Partial approved reason" ) - reason_suspended = fields.Selection("_reason_suspended", string="Reason") - reason_refused = fields.Selection("_reason_refused", string="Reason") + reason_suspended = fields.Selection("_reason_suspended", string="Suspended reason") + reason_refused = fields.Selection("_reason_refused", string="Refused reason") action = fields.Selection("_action_selection") comment = fields.Text(required=True) From 9aa015bd312a106a9eb6c74a7e75f29d17d30f24 Mon Sep 17 00:00:00 2001 From: Pierre Verkest Date: Tue, 23 Jun 2026 23:38:15 +0200 Subject: [PATCH 2/2] [IMP] l10n_fr_einvoicing: add unitest --- l10n_fr_einvoicing/tests/__init__.py | 13 + l10n_fr_einvoicing/tests/test_account_move.py | 357 +++++++ .../tests/test_fr_directory_line.py | 132 +++ .../tests/test_fr_einvoicing_event.py | 131 +++ .../tests/test_fr_einvoicing_event_manual.py | 311 ++++++ .../tests/test_fr_einvoicing_flow.py | 945 ++++++++++++++++++ .../tests/test_fr_einvoicing_log.py | 141 +++ .../tests/test_fr_einvoicing_sync.py | 451 +++++++++ .../tests/test_res_config_settings.py | 30 + l10n_fr_einvoicing/tests/test_res_partner.py | 209 ++++ 10 files changed, 2720 insertions(+) create mode 100644 l10n_fr_einvoicing/tests/__init__.py create mode 100644 l10n_fr_einvoicing/tests/test_account_move.py create mode 100644 l10n_fr_einvoicing/tests/test_fr_directory_line.py create mode 100644 l10n_fr_einvoicing/tests/test_fr_einvoicing_event.py create mode 100644 l10n_fr_einvoicing/tests/test_fr_einvoicing_event_manual.py create mode 100644 l10n_fr_einvoicing/tests/test_fr_einvoicing_flow.py create mode 100644 l10n_fr_einvoicing/tests/test_fr_einvoicing_log.py create mode 100644 l10n_fr_einvoicing/tests/test_fr_einvoicing_sync.py create mode 100644 l10n_fr_einvoicing/tests/test_res_config_settings.py create mode 100644 l10n_fr_einvoicing/tests/test_res_partner.py diff --git a/l10n_fr_einvoicing/tests/__init__.py b/l10n_fr_einvoicing/tests/__init__.py new file mode 100644 index 0000000..00c094a --- /dev/null +++ b/l10n_fr_einvoicing/tests/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_fr_einvoicing_log +from . import test_fr_directory_line +from . import test_fr_einvoicing_sync +from . import test_account_move +from . import test_fr_einvoicing_flow +from . import test_fr_einvoicing_event +from . import test_res_config_settings +from . import test_fr_einvoicing_event_manual +from . import test_res_partner diff --git a/l10n_fr_einvoicing/tests/test_account_move.py b/l10n_fr_einvoicing/tests/test_account_move.py new file mode 100644 index 0000000..45a0163 --- /dev/null +++ b/l10n_fr_einvoicing/tests/test_account_move.py @@ -0,0 +1,357 @@ +# Copyright 2026 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import timedelta +from unittest.mock import patch + +from odoo import fields +from odoo.exceptions import UserError + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +class TestAccountMove(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.user.company_id + cls.company.write( + { + "name": "Test Company Main", + "country_id": cls.env.ref("base.fr").id, + "fr_ctc_accredited_platform": "superpdp", + "fr_ctc_auth_method": "client_credentials", + "fr_ctc_client_id": "client_id", + "fr_ctc_client_secret": "client_secret", + "fr_ctc_directory_sync_on_invoice_post": "no", + } + ) + cls.company.partner_id.write( + { + "siren": "120027016", + "country_id": cls.env.ref("base.fr").id, + "is_company": True, + "fr_directory_entity_type": "private", + "fr_directory_last_sync_date": fields.Date.today(), + } + ) + cls.company_dir_line = cls.env["fr.directory.line"].create( + { + "partner_id": cls.company.partner_id.id, + "identifier": "120027016", + "type": "siren", + "state": "active", + } + ) + + cls.customer = cls.env["res.partner"].create( + { + "name": "French Customer Partner", + "siren": "552081317", + "country_id": cls.env.ref("base.fr").id, + "is_company": True, + "fr_directory_entity_type": "private", + "fr_directory_last_sync_date": fields.Date.today(), + } + ) + cls.customer_dir_line = cls.env["fr.directory.line"].create( + { + "partner_id": cls.customer.id, + "identifier": "552081317", + "type": "siren", + "state": "active", + } + ) + + def _create_invoice(self): + return self.env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": self.customer.id, + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": "Line 1", + "quantity": 1, + "price_unit": 100.0, + }, + ) + ], + } + ) + + def test_invoice_computes_directory_lines(self): + invoice = self._create_invoice() + self.assertEqual(invoice.fr_directory_line_id, self.customer_dir_line) + self.assertEqual(invoice.company_fr_directory_line_id, self.company_dir_line) + self.assertTrue(invoice.fr_einvoicing_required) + + def test_invoice_post_raises_if_company_dir_line_inactive(self): + invoice = self._create_invoice() + self.company_dir_line.state = "inactive" + with self.assertRaisesRegex( + UserError, "the selected company directory line .* is not active" + ): + invoice.action_post() + self.company_dir_line.state = "active" + + def test_invoice_post_raises_if_customer_dir_line_inactive(self): + invoice = self._create_invoice() + self.customer_dir_line.state = "inactive" + with self.assertRaisesRegex(UserError, "is not active"): + invoice.action_post() + self.customer_dir_line.state = "active" + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.ResPartner._fr_directory_sync_logs" + ) + def test_invoice_post_creates_flow_successfully(self, mock_sync_logs): + invoice = self._create_invoice() + invoice.action_post() + self.assertTrue(invoice.fr_einvoicing_flow_id) + self.assertEqual(invoice.fr_einvoicing_flow_id.move_id, invoice) + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.ResPartner._fr_directory_sync_logs" + ) + def test_fr_directory_sync_action_server_with_move_context(self, mock_sync): + invoice = self._create_invoice() + ctx = {"fr_directory_sync_move_id": invoice.id} + action = invoice.with_context(**ctx)._fr_directory_sync_action_server() + self.assertEqual(action["res_id"], invoice.id) + + def test_fr_directory_sync_action_server_without_move_context(self): + invoice = self._create_invoice() + action = invoice._fr_directory_sync_action_server() + self.assertEqual(action, {}) + + @patch("odoo.addons.l10n_fr_einvoicing.models.account_move.get_flow") + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + def test_fr_ctc_get_readable_invoice_button_success( + self, mock_session, mock_get_flow + ): + mock_get_flow.return_value = b"PDF_CONTENT" + purchase_invoice = self.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": self.customer.id, + "ref": "BILL_999", + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": "Line 1", + "quantity": 1, + "price_unit": 100.0, + }, + ) + ], + } + ) + flow = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "state": "created", + "direction": "in", + "identifier": "FLOW_BILL", + "syntax": "UBL", + "move_ids": [(6, 0, [purchase_invoice.id])], + } + ) + ) + purchase_invoice.sudo().fr_einvoicing_flow_id = flow.id + purchase_invoice._compute_fr_einvoicing_show_readable_invoice_button() + self.assertTrue(purchase_invoice.fr_einvoicing_show_readable_invoice_button) + + action = purchase_invoice.fr_ctc_get_readable_invoice_button() + self.assertEqual(action["type"], "ir.actions.client") + self.assertFalse(purchase_invoice.fr_einvoicing_show_readable_invoice_button) + + attachment = self.env["ir.attachment"].search( + [ + ("res_model", "=", "account.move"), + ("res_id", "=", purchase_invoice.id), + ("name", "=", "readable_BILL_999.pdf"), + ] + ) + self.assertTrue(attachment.exists()) + self.assertEqual(attachment.raw, b"PDF_CONTENT") + + @patch("odoo.addons.l10n_fr_einvoicing.models.account_move.get_flow") + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + def test_fr_ctc_get_readable_invoice_button_failure( + self, mock_session, mock_get_flow + ): + mock_get_flow.side_effect = Exception("API connection timeout") + purchase_invoice = self.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": self.customer.id, + "ref": "BILL_ERR", + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": "Line 1", + "quantity": 1, + "price_unit": 100.0, + }, + ) + ], + } + ) + flow = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "state": "created", + "direction": "in", + "identifier": "FLOW_ERR", + "syntax": "UBL", + "move_ids": [(6, 0, [purchase_invoice.id])], + } + ) + ) + purchase_invoice.sudo().fr_einvoicing_flow_id = flow.id + + with self.assertRaisesRegex( + UserError, "Failed to get the readable version of vendor bill" + ): + purchase_invoice.fr_ctc_get_readable_invoice_button() + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.ResPartner._fr_directory_sync_logs" + ) + def test_invoice_post_with_missing_partner_directory_entity_type_triggers_sync_success( # noqa: E501 + self, mock_sync_logs + ): + self.customer.write( + { + "fr_directory_entity_type": False, + "fr_directory_last_sync_date": False, + } + ) + self.company.write( + { + "fr_ctc_directory_sync_on_invoice_post": "not_blocking", + } + ) + + # Mock successful sync setting private status + def side_effect(company, origin): + self.customer.write( + { + "fr_directory_entity_type": "private", + "fr_directory_last_sync_date": fields.Date.today(), + } + ) + + mock_sync_logs.side_effect = side_effect + + invoice = self._create_invoice() + invoice.action_post() + self.assertEqual(self.customer.fr_directory_entity_type, "private") + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.ResPartner._fr_directory_sync_logs" + ) + def test_invoice_post_with_missing_partner_directory_entity_type_triggers_sync_failure( # noqa: E501 + self, mock_sync_logs + ): + self.customer.write( + { + "fr_directory_entity_type": False, + "fr_directory_last_sync_date": False, + } + ) + self.company.write( + { + "fr_ctc_directory_sync_on_invoice_post": "not_blocking", + } + ) + mock_sync_logs.side_effect = Exception("Directory Down") + + invoice = self._create_invoice() + invoice.action_post() + # Post should succeed even if sync fails because it is "not_blocking" + self.assertEqual(invoice.state, "posted") + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.ResPartner._fr_directory_sync_logs" + ) + def test_invoice_post_with_old_sync_date_triggers_sync_success( + self, mock_sync_logs + ): + self.customer.write( + { + "fr_directory_entity_type": "private", + "fr_directory_last_sync_date": fields.Date.today() - timedelta(days=10), + } + ) + self.company.write( + { + "fr_ctc_directory_sync_on_invoice_post": "not_blocking", + } + ) + + invoice = self._create_invoice() + invoice.action_post() + mock_sync_logs.assert_called_once() + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.ResPartner._fr_directory_sync_logs" + ) + def test_invoice_post_with_old_sync_date_triggers_sync_blocking_failure( + self, mock_sync_logs + ): + self.customer.write( + { + "fr_directory_entity_type": "private", + "fr_directory_last_sync_date": fields.Date.today() - timedelta(days=10), + } + ) + self.company.write( + { + "fr_ctc_directory_sync_on_invoice_post": "blocking", + } + ) + mock_sync_logs.side_effect = Exception("Directory Down") + + invoice = self._create_invoice() + with self.assertRaisesRegex( + UserError, "Failed to query the directory for partner" + ): + invoice.action_post() + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany.fr_ctc_run_import_log" + ) + def test_journal_run_import_button(self, mock_run_import_log): + journal = self.env["account.journal"].create( + { + "name": "Customer Invoice Journal Test", + "code": "TESTJ", + "type": "sale", + "company_id": self.company.id, + } + ) + mock_run_import_log.return_value = ( + None, + {"new_count": 0, "warning_count": 0, "error_count": 0}, + ) + action = journal.fr_einvoicing_run_import_button() + self.assertEqual(action["type"], "ir.actions.client") diff --git a/l10n_fr_einvoicing/tests/test_fr_directory_line.py b/l10n_fr_einvoicing/tests/test_fr_directory_line.py new file mode 100644 index 0000000..e09545c --- /dev/null +++ b/l10n_fr_einvoicing/tests/test_fr_directory_line.py @@ -0,0 +1,132 @@ +# Copyright 2026 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from psycopg2 import IntegrityError + +from odoo.tools import mute_logger + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +class TestFrDirectoryLine(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create( + { + "name": "Test Partner Directory Line", + } + ) + + def test_directory_line_active_computation(self): + line_active = self.env["fr.directory.line"].create( + { + "partner_id": self.partner.id, + "identifier": "123456789", + "type": "siren", + "state": "active", + } + ) + line_disabled = self.env["fr.directory.line"].create( + { + "partner_id": self.partner.id, + "identifier": "987654321", + "type": "siren", + "state": "disabled", + } + ) + + self.assertTrue(line_active.active) + self.assertFalse(line_disabled.active) + + def test_directory_line_display_name_computation(self): + line_routing = self.env["fr.directory.line"].create( + { + "partner_id": self.partner.id, + "identifier": "ROUTING123", + "type": "routing_code", + "routing_code_name": "Test Code", + "state": "active", + } + ) + line_inactive = self.env["fr.directory.line"].create( + { + "partner_id": self.partner.id, + "identifier": "111222333", + "type": "siren", + "state": "inactive", + } + ) + + self.assertEqual(line_routing.display_name, "ROUTING123 Test Code") + self.assertEqual(line_inactive.display_name, "[Inactive] 111222333") + + def test_confirm_common_checks_when_inactive_returns_error(self): + line = self.env["fr.directory.line"].create( + { + "partner_id": self.partner.id, + "identifier": "111222333", + "type": "siren", + "state": "inactive", + } + ) + + error = line._confirm_common_checks(commitment_ref=False, origin="INV1") + + self.assertIsNotNone(error) + self.assertIn("is not active", error) + + def test_confirm_common_checks_when_commitment_required_and_missing_returns_error( + self, + ): + line = self.env["fr.directory.line"].create( + { + "partner_id": self.partner.id, + "identifier": "111222333", + "type": "siren", + "state": "active", + "commitment_required": True, + } + ) + + error = line._confirm_common_checks(commitment_ref=False, origin="INV1") + + self.assertIsNotNone(error) + self.assertIn("requires a commitment reference", error) + + def test_confirm_common_checks_when_valid_returns_none(self): + line = self.env["fr.directory.line"].create( + { + "partner_id": self.partner.id, + "identifier": "111222333", + "type": "siren", + "state": "active", + "commitment_required": True, + } + ) + + error = line._confirm_common_checks(commitment_ref="REF123", origin="INV1") + + self.assertIsNone(error) + + @mute_logger("odoo.sql_db") + def test_partner_identifier_unique_constraint(self): + self.env["fr.directory.line"].create( + { + "partner_id": self.partner.id, + "identifier": "111222333", + "type": "siren", + "state": "active", + } + ) + + with self.assertRaises(IntegrityError): + self.env["fr.directory.line"].create( + { + "partner_id": self.partner.id, + "identifier": "111222333", + "type": "siren", + "state": "active", + } + ) diff --git a/l10n_fr_einvoicing/tests/test_fr_einvoicing_event.py b/l10n_fr_einvoicing/tests/test_fr_einvoicing_event.py new file mode 100644 index 0000000..f47412b --- /dev/null +++ b/l10n_fr_einvoicing/tests/test_fr_einvoicing_event.py @@ -0,0 +1,131 @@ +# Copyright 2026 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +class TestFrEinvoicingEvent(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.user.company_id + cls.customer = cls.env["res.partner"].create( + { + "name": "French Customer Partner Event", + "siren": "552081317", + "country_id": cls.env.ref("base.fr").id, + "is_company": True, + } + ) + cls.invoice = cls.env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": cls.customer.id, + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": "Line 1", + "quantity": 1, + "price_unit": 100.0, + }, + ) + ], + } + ) + cls.flow = ( + cls.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": cls.company.id, + "state": "created", + "direction": "out", + "move_ids": [(6, 0, [cls.invoice.id])], + } + ) + ) + cls.event = ( + cls.env["fr.einvoicing.event"] + .sudo() + .create( + { + "flow_id": cls.flow.id, + "status": "approved", + "direction": "in", + "company_id": cls.company.id, + "move_id": cls.invoice.id, + } + ) + ) + + def test_status_selection_returns_non_empty_list(self): + status_sel = self.event._status_selection() + self.assertTrue(len(status_sel) > 0) + + def test_status_selection_manual_returns_non_empty_lists(self): + manual_sale = self.event._status_selection_manual("sale") + manual_purchase = self.event._status_selection_manual("purchase") + self.assertTrue(len(manual_sale) > 0) + self.assertTrue(len(manual_purchase) > 0) + + def test_get_status_key_for_submitted(self): + status_key = self.event._get_status_key("200") + self.assertEqual(status_key, "submitted") + + def test_convert_datetime2str_returns_formatted_string(self): + dt = datetime(2026, 4, 30, 13, 37, 56) + dt_str = self.event._convert_datetime2str(dt, "%Y-%m-%dT%H:%M:%S") + self.assertEqual(dt_str, "2026-04-30T13:37:56") + + def test_compute_status_decoration_returns_expected_decoration(self): + self.event.status = "approved" + self.event._compute_status_decoration() + self.assertEqual(self.event.status_decoration, "success") + + self.event.status = "rejected" + self.event._compute_status_decoration() + self.assertEqual(self.event.status_decoration, "danger") + + def test_compute_display_name_includes_status(self): + self.event.status = "rejected" + self.event._compute_display_name() + self.assertIn("Rejected", self.event.display_name) + + def test_event_detail_selection_methods_return_valid_data(self): + detail_model = self.env["fr.einvoicing.event.detail"] + all_reasons = detail_model._get_all_reasons() + self.assertTrue(isinstance(all_reasons, dict)) + + reasons_sel = detail_model._reason_selection() + self.assertTrue(len(reasons_sel) > 0) + + actions_sel = detail_model._action_selection() + self.assertTrue(len(actions_sel) > 0) + + def test_prepare_xml_data_out_invoice(self): + self.company.partner_id.write( + { + "siren": "120027016", + } + ) + self.invoice.write( + { + "invoice_date": datetime(2026, 4, 30).date(), + "fr_directory_line_identifier": "120027016", + "name": "INV/2026/0001", + } + ) + self.event.write( + { + "status": "approved", + } + ) + data = self.event._prepare_xml_data() + self.assertEqual(data["MDT-88"], "1") + self.assertEqual(data["MDT-87"], "INV/2026/0001") + self.assertEqual(data["MDT-73"], "120027016") diff --git a/l10n_fr_einvoicing/tests/test_fr_einvoicing_event_manual.py b/l10n_fr_einvoicing/tests/test_fr_einvoicing_event_manual.py new file mode 100644 index 0000000..7b2f33a --- /dev/null +++ b/l10n_fr_einvoicing/tests/test_fr_einvoicing_event_manual.py @@ -0,0 +1,311 @@ +# Copyright 2026 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import copy +from unittest.mock import patch + +from odoo.exceptions import UserError + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +class TestFrEinvoicingEventManual(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.user.company_id + cls.customer = cls.env["res.partner"].create( + { + "name": "French Customer Partner Wizard", + "siren": "552081317", + "country_id": cls.env.ref("base.fr").id, + "is_company": True, + } + ) + cls.invoice_sale = cls.env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": cls.customer.id, + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": "Line 1", + "quantity": 1, + "price_unit": 100.0, + }, + ) + ], + } + ) + cls.flow_sale = ( + cls.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": cls.company.id, + "state": "created", + "direction": "out", + "move_ids": [(6, 0, [cls.invoice_sale.id])], + } + ) + ) + cls.invoice_sale.sudo().fr_einvoicing_flow_id = cls.flow_sale.id + + cls.invoice_purchase = cls.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": cls.customer.id, + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": "Line 1", + "quantity": 1, + "price_unit": 100.0, + }, + ) + ], + } + ) + cls.flow_purchase = ( + cls.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": cls.company.id, + "state": "created", + "direction": "in", + "move_ids": [(6, 0, [cls.invoice_purchase.id])], + } + ) + ) + cls.invoice_purchase.sudo().fr_einvoicing_flow_id = cls.flow_purchase.id + + def test_default_get_loads_status_selections(self): + ctx = { + "active_model": "account.move", + "active_id": self.invoice_sale.id, + } + wiz = self.env["fr.einvoicing.event.manual"].with_context(**ctx).create({}) + self.assertTrue(len(wiz._status_sale_selection()) > 0) + self.assertTrue(len(wiz._status_purchase_selection()) > 0) + + def test_empty_status_computes_no_required_fields(self): + ctx = { + "active_model": "account.move", + "active_id": self.invoice_sale.id, + } + wiz = self.env["fr.einvoicing.event.manual"].with_context(**ctx).create({}) + wiz._compute_required_fields() + self.assertFalse(wiz.detail_required) + + def test_status_completed_on_sales_document_creates_event_and_copies_attachments( + self, + ): + ctx = { + "active_model": "account.move", + "active_id": self.invoice_sale.id, + } + wiz = self.env["fr.einvoicing.event.manual"].with_context(**ctx).create({}) + wiz.status_sale = "completed" + wiz._compute_required_fields() + self.assertFalse(wiz.detail_required) + self.assertFalse(wiz.confirm_required) + + attachment = self.env["ir.attachment"].create( + { + "name": "test.txt", + "raw": b"Hello world", + } + ) + wiz.attachment_ids = [(6, 0, [attachment.id])] + + wiz.run() + event = ( + self.env["fr.einvoicing.event"] + .sudo() + .search([("move_id", "=", self.invoice_sale.id)]) + ) + self.assertEqual(event.status, "completed") + self.assertTrue(event.attachment_ids.exists()) + + def test_status_dispute_requires_details_and_raises_user_error(self): + ctx = { + "active_model": "account.move", + "active_id": self.invoice_purchase.id, + } + wiz = ( + self.env["fr.einvoicing.event.manual"] + .with_context(**ctx) + .create( + { + "status_purchase": "dispute", + } + ) + ) + wiz._compute_required_fields() + self.assertTrue(wiz.detail_required) + + with self.assertRaisesRegex( + UserError, "you must create at least one line of details" + ): + wiz.run() + + def test_status_dispute_with_details_creates_event_successfully(self): + ctx = { + "active_model": "account.move", + "active_id": self.invoice_purchase.id, + } + wiz = ( + self.env["fr.einvoicing.event.manual"] + .with_context(**ctx) + .create( + { + "status_purchase": "dispute", + } + ) + ) + self.env["fr.einvoicing.event.detail.manual"].create( + { + "event_id": wiz.id, + "reason_dispute": "JUSTIF_ABS", + "comment": "Missing documentation", + } + ) + wiz.run() + event = ( + self.env["fr.einvoicing.event"] + .sudo() + .search( + [("move_id", "=", self.invoice_purchase.id), ("status", "=", "dispute")] + ) + ) + self.assertTrue(event.exists()) + + def test_status_refused_without_details_raises_user_error(self): + ctx = { + "active_model": "account.move", + "active_id": self.invoice_purchase.id, + } + wiz = ( + self.env["fr.einvoicing.event.manual"] + .with_context(**ctx) + .create( + { + "status_purchase": "refused", + } + ) + ) + wiz._compute_required_fields() + with self.assertRaisesRegex( + UserError, "you must create at least one line of details" + ): + wiz.run() + + def test_status_refused_without_confirmation_raises_user_error(self): + ctx = { + "active_model": "account.move", + "active_id": self.invoice_purchase.id, + } + wiz = ( + self.env["fr.einvoicing.event.manual"] + .with_context(**ctx) + .create( + { + "status_purchase": "refused", + } + ) + ) + self.env["fr.einvoicing.event.detail.manual"].create( + { + "event_id": wiz.id, + "reason_refused": "JUSTIF_ABS", + "comment": "Refused bill", + } + ) + wiz._compute_required_fields() + with self.assertRaisesRegex(UserError, "You must confirm the refusal"): + wiz.run() + + def test_status_refused_with_details_and_confirmation_creates_event_successfully( + self, + ): + ctx = { + "active_model": "account.move", + "active_id": self.invoice_purchase.id, + } + wiz = ( + self.env["fr.einvoicing.event.manual"] + .with_context(**ctx) + .create( + { + "status_purchase": "refused", + } + ) + ) + self.env["fr.einvoicing.event.detail.manual"].create( + { + "event_id": wiz.id, + "reason_refused": "JUSTIF_ABS", + "comment": "Refused bill", + } + ) + wiz.confirm = True + wiz.run() + event = ( + self.env["fr.einvoicing.event"] + .sudo() + .search( + [("move_id", "=", self.invoice_purchase.id), ("status", "=", "refused")] + ) + ) + self.assertTrue(event.exists()) + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_event.FrEinvoicingEvent._get_all_status" + ) + def test_manual_event_wizard_generic_confirm(self, mock_status): + all_status = self.env["fr.einvoicing.event"]._get_all_status() + all_status_copy = copy.deepcopy(all_status) + all_status_copy["dispute"]["confirm_required"] = True + mock_status.return_value = all_status_copy + + ctx = { + "active_model": "account.move", + "active_id": self.invoice_purchase.id, + } + wiz = ( + self.env["fr.einvoicing.event.manual"] + .with_context(**ctx) + .create( + { + "status_purchase": "dispute", + "confirm": False, + } + ) + ) + self.env["fr.einvoicing.event.detail.manual"].create( + { + "event_id": wiz.id, + "reason_dispute": "JUSTIF_ABS", + "comment": "Missing documentation", + } + ) + wiz._compute_required_fields() + self.assertTrue(wiz.confirm_required) + with self.assertRaisesRegex( + UserError, "You must confirm the creation of the event" + ): + wiz.run() + + def test_transient_selection_methods(self): + detail_model = self.env["fr.einvoicing.event.detail.manual"] + self.assertTrue(len(detail_model._reason_dispute_selection()) > 0) + self.assertTrue(len(detail_model._reason_partially_approved_selection()) > 0) + self.assertTrue(len(detail_model._reason_suspended()) > 0) + self.assertTrue(len(detail_model._reason_refused()) > 0) + self.assertTrue(len(detail_model._action_selection()) > 0) diff --git a/l10n_fr_einvoicing/tests/test_fr_einvoicing_flow.py b/l10n_fr_einvoicing/tests/test_fr_einvoicing_flow.py new file mode 100644 index 0000000..3886e26 --- /dev/null +++ b/l10n_fr_einvoicing/tests/test_fr_einvoicing_flow.py @@ -0,0 +1,945 @@ +# Copyright 2026 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import datetime +from unittest.mock import MagicMock, patch + +from odoo.exceptions import UserError + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +class TestFrEinvoicingFlow(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.user.company_id + cls.company.write( + { + "name": "Test Company Flow", + "country_id": cls.env.ref("base.fr").id, + "fr_ctc_accredited_platform": "superpdp", + "fr_ctc_auth_method": "client_credentials", + "fr_ctc_client_id": "client_id", + "fr_ctc_client_secret": "client_secret", + } + ) + cls.customer = cls.env["res.partner"].create( + { + "name": "French Customer Partner Flow", + "siren": "552081317", + "country_id": cls.env.ref("base.fr").id, + "is_company": True, + } + ) + cls.invoice = cls.env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": cls.customer.id, + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": "Line 1", + "quantity": 1, + "price_unit": 100.0, + }, + ) + ], + } + ) + cls.flow = ( + cls.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": cls.company.id, + "state": "created", + "direction": "out", + "move_ids": [(6, 0, [cls.invoice.id])], + } + ) + ) + + def test_compute_display_name_when_no_identifier(self): + self.flow.sudo()._compute_display_name() + self.assertIn("to send", self.flow.display_name) + self.assertIn("Created", self.flow.display_name) + + def test_compute_display_name_with_identifier(self): + self.flow.sudo().identifier = "FLOW_123" + self.flow.sudo()._compute_display_name() + self.assertEqual(self.flow.display_name, "FLOW_123 (Created)") + + def test_compute_move_id_matches_linked_invoice(self): + self.flow.sudo()._compute_move_id() + self.assertEqual(self.flow.move_id, self.invoice) + + def test_compute_event_id_matches_linked_event(self): + event = ( + self.env["fr.einvoicing.event"] + .sudo() + .create( + { + "flow_id": self.flow.id, + "status": "approved", + "direction": "in", + "company_id": self.company.id, + } + ) + ) + self.flow.invalidate_recordset(["event_ids"]) + self.flow.sudo()._compute_event_id() + self.assertEqual(self.flow.event_id, event) + + def test_unlink_allowed_when_no_identifier(self): + self.flow.sudo().identifier = False + self.flow.sudo().unlink() + self.assertFalse(self.flow.exists()) + + def test_unlink_raises_user_error_when_identifier_exists(self): + flow_with_id = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "state": "created", + "direction": "out", + "identifier": "FLOW_999", + } + ) + ) + with self.assertRaisesRegex( + UserError, "Cannot delete flow .* because it has an identifier" + ): + flow_with_id.sudo().unlink() + + def test_show_invoices_button_raises_user_error_when_no_invoice(self): + flow_no_invoice = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "state": "created", + "direction": "out", + } + ) + ) + with self.assertRaisesRegex(UserError, "is not linked to an invoice"): + flow_no_invoice.sudo().show_invoices_button() + + def test_show_invoices_button_returns_action_when_invoice_exists(self): + action = self.flow.sudo().show_invoices_button() + self.assertEqual(action["res_id"], self.invoice.id) + + def test_back2created_button_moves_state_from_error_to_created(self): + self.flow.sudo().state = "error" + self.flow.sudo().back2created_button() + self.assertEqual(self.flow.state, "created") + + def test_back2created_button_raises_assertion_error_when_state_is_not_error(self): + self.flow.sudo().state = "created" + with self.assertRaises(AssertionError): + self.flow.sudo().back2created_button() + + def test_generate_button_skips_when_direction_is_incoming(self): + flow_in = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "state": "created", + "direction": "in", + } + ) + ) + flow_in.generate_button() + self.assertEqual(flow_in.state, "created") + + def test_generate_button_skips_when_state_is_not_created(self): + flow_error = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "state": "error", + "direction": "out", + } + ) + ) + flow_error.generate_button() + self.assertEqual(flow_error.state, "error") + + def test_generate_button_skips_when_file_is_already_attached(self): + flow_with_file = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "state": "created", + "direction": "out", + "file_bin": b"dGVzdA==", + } + ) + ) + flow_with_file.generate_button() + self.assertEqual(flow_with_file.state, "created") + + def test_generate_button_syntax_ubl_sets_state_to_generated_or_error(self): + flow_ubl = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "state": "created", + "direction": "out", + "syntax": "UBL", + "move_ids": [(6, 0, [self.invoice.id])], + } + ) + ) + flow_ubl.generate_button() + self.assertIn(flow_ubl.state, ["generated", "error"]) + + def test_generate_button_syntax_cii_sets_state_to_generated_or_error(self): + flow_cii = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "state": "created", + "direction": "out", + "syntax": "CII", + "move_ids": [(6, 0, [self.invoice.id])], + } + ) + ) + flow_cii.generate_button() + self.assertIn(flow_cii.state, ["generated", "error"]) + + @patch("odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.generate_cdar") + @patch( + "odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_event.FrEinvoicingEvent._prepare_xml_data" + ) + def test_generate_button_for_event_successfully_generates_cdar( + self, mock_prepare_xml, mock_generate_cdar + ): + mock_prepare_xml.return_value = {"some": "data"} + mock_generate_cdar.return_value = b"CDAR" + + event = ( + self.env["fr.einvoicing.event"] + .sudo() + .create( + { + "status": "approved", + "direction": "out", + "company_id": self.company.id, + "move_id": self.invoice.id, + } + ) + ) + flow_event = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "state": "created", + "direction": "out", + "event_ids": [(6, 0, [event.id])], + } + ) + ) + flow_event.generate_button() + self.assertEqual(flow_event.state, "generated") + self.assertEqual(flow_event.filename, "cdar_approved.xml") + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + def test_send_button_when_direction_is_in_does_nothing(self, mock_session): + self.flow.direction = "in" + self.flow.state = "generated" + self.flow.file_bin = b"dGVzdA==" + self.flow.filename = "test.xml" + self.flow.syntax = "UBL" + self.flow.processing_rule = "B2B" + + self.flow.send_button() + self.assertEqual(self.flow.state, "generated") + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + def test_send_button_when_state_is_not_generated_does_nothing(self, mock_session): + self.flow.direction = "out" + self.flow.state = "created" + self.flow.file_bin = b"dGVzdA==" + self.flow.filename = "test.xml" + self.flow.syntax = "UBL" + self.flow.processing_rule = "B2B" + + self.flow.send_button() + self.assertEqual(self.flow.state, "created") + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + def test_send_button_when_no_file_does_nothing(self, mock_session): + self.flow.direction = "out" + self.flow.state = "generated" + self.flow.file_bin = False + self.flow.filename = "test.xml" + self.flow.syntax = "UBL" + self.flow.processing_rule = "B2B" + + self.flow.send_button() + self.assertEqual(self.flow.state, "generated") + + @patch("odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.send_flow_parsed") + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + def test_send_button_success_updates_flow_state_to_sent( + self, mock_session, mock_send + ): + mock_send.return_value = { + "flowId": "i_123", + "flowSyntax": "UBL", + "submittedAt": "2026-04-30T13:10:47Z", + } + self.flow.direction = "out" + self.flow.state = "generated" + self.flow.file_bin = b"dGVzdA==" + self.flow.filename = "test.xml" + self.flow.syntax = "UBL" + self.flow.processing_rule = "B2B" + + self.flow.send_button() + self.assertEqual(self.flow.state, "sent") + self.assertEqual(self.flow.identifier, "i_123") + + @patch("odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.send_flow_parsed") + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + def test_send_button_failure_updates_flow_state_to_error( + self, mock_session, mock_send + ): + mock_send.side_effect = Exception("Connection Failed") + self.flow.direction = "out" + self.flow.state = "generated" + self.flow.file_bin = b"dGVzdA==" + self.flow.filename = "test.xml" + self.flow.syntax = "UBL" + self.flow.processing_rule = "B2B" + + self.flow.send_button() + self.assertEqual(self.flow.state, "generated") + self.assertIn("Connection Failed", self.flow.odoo_error_details) + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + def test_download_button_when_direction_is_out_does_nothing(self, mock_session): + self.flow.direction = "out" + self.flow.state = "created" + self.flow.identifier = "FLOW_123" + + self.flow.download_button() + self.assertEqual(self.flow.state, "created") + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + def test_download_button_when_state_is_not_created_does_nothing(self, mock_session): + self.flow.direction = "in" + self.flow.state = "downloaded" + self.flow.identifier = "FLOW_123" + + self.flow.download_button() + self.assertEqual(self.flow.state, "downloaded") + + @patch("odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.get_flow") + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + def test_download_button_success_updates_flow_state_to_downloaded( + self, mock_session, mock_get + ): + mock_get.return_value = b"Original XML" + self.flow.direction = "in" + self.flow.state = "created" + self.flow.identifier = "FLOW_DOWNLOAD" + self.flow.syntax = "UBL" + + self.flow.download_button() + self.assertEqual(self.flow.state, "downloaded") + self.assertEqual(self.flow.filename, "FLOW_DOWNLOAD.xml") + + def test_process_button_when_direction_is_out_does_nothing(self): + self.flow.direction = "out" + self.flow.state = "downloaded" + self.flow.file_bin = b"dGVzdA==" + + self.flow.process_button() + self.assertEqual(self.flow.state, "downloaded") + + def test_process_button_when_state_is_not_downloaded_does_nothing(self): + self.flow.direction = "in" + self.flow.state = "created" + self.flow.file_bin = b"dGVzdA==" + + self.flow.process_button() + self.assertEqual(self.flow.state, "created") + + def test_process_button_supplier_invoice_without_import_sets_error(self): + self.flow.direction = "in" + self.flow.state = "downloaded" + self.flow.file_bin = b"dGVzdA==" + self.flow.type = "SupplierInvoice" + + self.flow.process_button() + self.assertEqual(self.flow.state, "error") + self.assertIn( + "Odoo failed to created the supplier invoice", self.flow.odoo_error_details + ) + + @patch("odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.parse_cdar") + def test_process_button_customer_invoice_lc_with_invalid_xml_sets_error( + self, mock_parse + ): + mock_parse.side_effect = Exception("XML parsing error") + self.flow.direction = "in" + self.flow.state = "downloaded" + self.flow.file_bin = b"dGVzdA==" + self.flow.type = "CustomerInvoiceLC" + + self.flow.process_button() + self.assertEqual(self.flow.state, "error") + self.assertIn("XML parsing error", self.flow.odoo_error_details) + + @patch("odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.parse_cdar") + @patch( + "odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.FrEinvoicingFlow._match_partner_from_event" + ) + def test_process_button_customer_invoice_lc_with_valid_xml_creates_event( + self, mock_match_partner, mock_parse + ): + mock_parse.return_value = { + "invoice_number": self.invoice.name, + "invoice_issuer": { + "0002": "120027016", + }, + "status": "approved", + "status_code": "205", + "lc_datetime": datetime.datetime.now(), + "lifecycle_id": "LC_123", + "identifier": "EVT_123", + } + mock_match_partner.return_value = self.customer + + self.flow.direction = "in" + self.flow.state = "downloaded" + self.flow.file_bin = b"dGVzdA==" + self.flow.type = "CustomerInvoiceLC" + + self.flow.process_button() + self.assertEqual(self.flow.state, "done") + + event = self.env["fr.einvoicing.event"].search( + [("move_id", "=", self.invoice.id)] + ) + self.assertTrue(event.exists()) + self.assertEqual(event.status, "approved") + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_event.FrEinvoicingEvent._prepare_xml_data" + ) + def test_generate_button_for_event_cdar_error_sets_error_state( + self, mock_prepare_xml + ): + mock_prepare_xml.side_effect = Exception("Mock XML Error") + + event = ( + self.env["fr.einvoicing.event"] + .sudo() + .create( + { + "status": "approved", + "direction": "out", + "company_id": self.company.id, + "move_id": self.invoice.id, + } + ) + ) + flow_event = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "state": "created", + "direction": "out", + "event_ids": [(6, 0, [event.id])], + } + ) + ) + flow_event.generate_button() + self.assertEqual(flow_event.state, "error") + self.assertIn("Mock XML Error", flow_event.odoo_error_details) + + @patch("odoo.addons.base.models.ir_actions_report.IrActionsReport._render") + def test_generate_button_facturx_error_sets_error_state(self, mock_render): + mock_render.side_effect = Exception("Mock Factur-X render Error") + self.flow.syntax = "Factur-X" + self.flow.generate_button() + self.assertEqual(self.flow.state, "error") + self.assertIn("Mock Factur-X render Error", self.flow.odoo_error_details) + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.FrEinvoicingFlow._cron_companies" + ) + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_run_import" + ) + def test_in_cron_success( + self, mock_run_import, mock_get_session, mock_cron_companies + ): + # Start fresh: unlink other flows so they do not interfere + self.env["fr.einvoicing.flow"].sudo().search([]).unlink() + + mock_cron_companies.return_value = self.company + mock_session = MagicMock() + mock_get_session.return_value = mock_session + + flow_dl = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "direction": "in", + "state": "created", + "identifier": "FLOW_DL_1", + } + ) + ) + flow_proc = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "direction": "in", + "state": "downloaded", + "file_bin": b"dGVzdA==", + "type": "SupplierInvoice", + } + ) + ) + + with ( + patch( + "odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.get_flow" + ) as mock_get_flow, + patch( + "odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.FrEinvoicingFlow._import_supplier_invoice" + ) as mock_import_inv, + ): + mock_get_flow.return_value = b"Original" + # Return real invoice ID to avoid MissingError on browsing in-hand event + mock_import_inv.return_value = self.invoice.id + + self.env["fr.einvoicing.flow"]._in_cron() + + self.assertEqual(flow_dl.state, "downloaded") + self.assertEqual(flow_proc.state, "done") + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.FrEinvoicingFlow._cron_companies" + ) + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + @patch("odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.send_flow_parsed") + @patch( + "odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.get_flow_metadata_parsed" + ) + @patch("time.sleep") + def test_out_cron_success( + self, + mock_sleep, + mock_get_metadata, + mock_send_flow, + mock_get_session, + mock_cron_companies, + ): + # Start fresh: unlink other flows so they do not interfere + self.env["fr.einvoicing.flow"].sudo().search([]).unlink() + + mock_cron_companies.return_value = self.company + mock_get_session.return_value = MagicMock() + mock_send_flow.return_value = { + "flowId": "sent_id_456", + "submitted_at": datetime.datetime.now(), + } + mock_get_metadata.return_value = { + "state": "done", + "updated_at": datetime.datetime.now(), + } + + flow_gen = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "direction": "out", + "state": "created", + "syntax": "UBL", + "move_ids": [(6, 0, [self.invoice.id])], + } + ) + ) + flow_send = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "direction": "out", + "state": "generated", + "file_bin": b"dGVzdA==", + "filename": "invoice.xml", + "syntax": "UBL", + "processing_rule": "B2B", + } + ) + ) + flow_update = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "direction": "out", + "state": "sent", + "identifier": "sent_id_123", + } + ) + ) + + with patch.object( + self.env["account.move"].__class__, + "generate_ubl_xml_string", + create=True, + return_value=b"UBL", + ): + self.env["fr.einvoicing.flow"]._out_cron() + + self.assertEqual(flow_gen.state, "generated") + self.assertEqual(flow_send.state, "done") + self.assertEqual(flow_update.state, "done") + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.FrEinvoicingFlow._cron_companies" + ) + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + def test_in_cron_session_error(self, mock_get_session, mock_cron_companies): + mock_cron_companies.return_value = self.company + mock_get_session.side_effect = Exception("Auth Failure") + + self.env["fr.einvoicing.flow"]._in_cron() + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.FrEinvoicingFlow._cron_companies" + ) + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + def test_out_cron_session_error(self, mock_get_session, mock_cron_companies): + mock_cron_companies.return_value = self.company + mock_get_session.side_effect = Exception("Auth Failure") + + self.env["fr.einvoicing.flow"]._out_cron() + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.get_flow_metadata_parsed" + ) + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + def test_update_status_api_error(self, mock_get_session, mock_get_metadata): + mock_get_session.return_value = MagicMock() + mock_get_metadata.side_effect = Exception("API Server error") + flow = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "direction": "out", + "state": "sent", + "identifier": "FLOW_SENT_ERR", + } + ) + ) + flow.update_status_button() + self.assertEqual(flow.state, "sent") + self.assertIn("API Server error", flow.odoo_error_details) + + def test_show_invoices_button_for_purchase_document(self): + purchase_invoice = self.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": self.customer.id, + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": "Line 1", + "quantity": 1, + "price_unit": 100.0, + }, + ) + ], + } + ) + flow_purchase = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "state": "created", + "direction": "in", + "move_ids": [(6, 0, [purchase_invoice.id])], + } + ) + ) + action = flow_purchase.show_invoices_button() + self.assertEqual(action["res_id"], purchase_invoice.id) + + @patch("odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.parse_cdar") + @patch( + "odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.FrEinvoicingFlow._match_partner_from_event" + ) + def test_process_creates_event_and_mail_activities( + self, mock_match_partner, mock_parse + ): + user = self.env.user + self.company.write( + { + "fr_ctc_activity_warning_event_user_ids": [(6, 0, [user.id])], + } + ) + + mock_parse.return_value = { + "invoice_number": self.invoice.name, + "invoice_issuer": { + "0002": "120027016", + }, + "status": "refused", + "status_code": "210", + "lc_datetime": datetime.datetime.now(), + "lifecycle_id": "LC_999", + "identifier": "EVT_999", + "attachments": [ + { + "filename": "error_log.txt", + "bin": b"RGV0YWlscyBvZiBlcnJvcg==", + } + ], + "doc_status": [ + { + "reason_code": "TX_TVA_ERR", + "comment": "rejected comments", + "action_code": "OTH", + "doc_characteristics": [ + { + "date": datetime.date.today(), + "amount": { + "currency": "EUR", + "float": 50.0, + }, + } + ], + } + ], + } + mock_match_partner.return_value = self.customer + + flow_lc = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "state": "downloaded", + "direction": "in", + "file_bin": b"dGVzdA==", + "type": "CustomerInvoiceLC", + } + ) + ) + + flow_lc.process_button() + self.assertEqual(flow_lc.state, "done") + + event = self.env["fr.einvoicing.event"].search( + [("move_id", "=", self.invoice.id)] + ) + self.assertTrue(event.exists()) + self.assertEqual(event.status, "refused") + self.assertEqual(len(event.detail_ids), 1) + self.assertEqual(event.detail_ids[0].comment, "rejected comments") + self.assertEqual(len(event.payment_ids), 1) + self.assertEqual(event.payment_ids[0].amount, 50.0) + + activity = self.env["mail.activity"].search( + [("res_model", "=", "account.move"), ("res_id", "=", self.invoice.id)] + ) + self.assertTrue(activity.exists()) + self.assertEqual(activity.user_id, user) + + @patch("odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.parse_cdar") + @patch( + "odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.FrEinvoicingFlow._match_partner_from_event" + ) + def test_process_supplier_invoice_lc_with_matching_bill( + self, mock_match_partner, mock_parse + ): + bill = self.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": self.customer.id, + "ref": "BILL-999", + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": "Line 1", + "quantity": 1, + "price_unit": 100.0, + }, + ) + ], + } + ) + mock_parse.return_value = { + "invoice_number": "BILL-999", + "invoice_issuer": { + "0002": "552081317", + }, + "status": "approved", + "status_code": "205", + "lc_datetime": datetime.datetime.now(), + "lifecycle_id": "LC_BILL", + "identifier": "EVT_BILL", + } + mock_match_partner.return_value = self.customer + + flow_lc = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "state": "downloaded", + "direction": "in", + "file_bin": b"dGVzdA==", + "type": "SupplierInvoiceLC", + } + ) + ) + flow_lc.process_button() + self.assertEqual(flow_lc.state, "done") + + event = self.env["fr.einvoicing.event"].search([("move_id", "=", bill.id)]) + self.assertTrue(event.exists()) + + @patch("odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.parse_cdar") + @patch( + "odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.FrEinvoicingFlow._match_partner_from_event" + ) + def test_process_supplier_invoice_lc_no_matching_bill_sets_error( + self, mock_match_partner, mock_parse + ): + mock_parse.return_value = { + "invoice_number": "BILL-NON-EXISTENT", + "invoice_issuer": { + "0002": "552081317", + }, + "status": "approved", + "status_code": "205", + "lc_datetime": datetime.datetime.now(), + "lifecycle_id": "LC_BILL", + "identifier": "EVT_BILL", + } + mock_match_partner.return_value = self.customer + + flow_lc = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "state": "downloaded", + "direction": "in", + "file_bin": b"dGVzdA==", + "type": "SupplierInvoiceLC", + } + ) + ) + flow_lc.process_button() + self.assertEqual(flow_lc.state, "error") + self.assertIn("No supplier invoice/refund found", flow_lc.odoo_error_details) + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + def test_send_button_missing_fields_warnings(self, mock_get_session): + mock_get_session.return_value = MagicMock() + flow = ( + self.env["fr.einvoicing.flow"] + .sudo() + .create( + { + "company_id": self.company.id, + "direction": "out", + "state": "generated", + "file_bin": b"dGVzdA==", + "filename": False, + "syntax": "UBL", + "processing_rule": "B2B", + } + ) + ) + flow.send_button() + self.assertEqual(flow.state, "generated") + + flow.write({"filename": "invoice.xml", "syntax": False}) + flow.send_button() + self.assertEqual(flow.state, "generated") + + flow.write({"syntax": "UBL", "processing_rule": False}) + flow.send_button() + self.assertEqual(flow.state, "generated") diff --git a/l10n_fr_einvoicing/tests/test_fr_einvoicing_log.py b/l10n_fr_einvoicing/tests/test_fr_einvoicing_log.py new file mode 100644 index 0000000..a5c6c15 --- /dev/null +++ b/l10n_fr_einvoicing/tests/test_fr_einvoicing_log.py @@ -0,0 +1,141 @@ +# Copyright 2026 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime, timedelta + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +class TestFrEinvoicingLog(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env["res.company"].create( + { + "name": "Test Company Log", + "country_id": cls.env.ref("base.fr").id, + } + ) + + def test_log_preparation_with_info_only_sets_success_status(self): + result = { + "log_type": "directory_all", + "log_origin": "Test Origin", + "company_id": self.company.id, + "logs": [], + "new_count": 5, + "updated_count": 2, + } + self.env["fr.einvoicing.log"]._info_log(result, "This is info message") + + log_vals = self.env["fr.einvoicing.log"]._prepare_log(result) + + self.assertEqual(log_vals["status"], "success") + self.assertEqual(result["error_count"], 0) + self.assertEqual(result["warning_count"], 0) + self.assertIn("INFO", log_vals["logs"]) + self.assertIn("This is info message", log_vals["logs"]) + + def test_log_preparation_with_warnings_sets_success_warn_status(self): + result = { + "log_type": "flow_import_single", + "log_origin": "Test Origin", + "company_id": self.company.id, + "logs": [], + } + self.env["fr.einvoicing.log"]._info_log(result, "Info message") + self.env["fr.einvoicing.log"]._warning_log(result, "Warning message") + + log_vals = self.env["fr.einvoicing.log"]._prepare_log(result) + + self.assertEqual(log_vals["status"], "success_warn") + self.assertEqual(result["error_count"], 0) + self.assertEqual(result["warning_count"], 1) + + def test_log_preparation_with_errors_sets_failure_status(self): + result = { + "log_type": "flow_send", + "log_origin": "Test Origin", + "company_id": self.company.id, + "logs": [], + } + self.env["fr.einvoicing.log"]._info_log(result, "Info message") + self.env["fr.einvoicing.log"]._warning_log(result, "Warning message") + self.env["fr.einvoicing.log"]._error_log(result, "Error message") + + log_vals = self.env["fr.einvoicing.log"]._prepare_log(result) + + self.assertEqual(log_vals["status"], "failure") + self.assertEqual(result["error_count"], 1) + self.assertEqual(result["warning_count"], 1) + + def test_create_log_persists_log_record(self): + result = { + "log_type": "flow_process", + "log_origin": "Test Origin", + "company_id": self.company.id, + "logs": [("info", "Process message")], + } + initial_count = self.env["fr.einvoicing.log"].search_count([]) + + self.env["fr.einvoicing.log"]._create_log(result) + + new_count = self.env["fr.einvoicing.log"].search_count([]) + self.assertEqual(new_count, initial_count + 1) + created_log = self.env["fr.einvoicing.log"].search([], limit=1) + self.assertEqual(created_log.type, "flow_process") + self.assertEqual(created_log.company_id, self.company) + + def test_gc_old_logs_removes_logs_older_than_configured_days(self): + result_new = { + "log_type": "flow_download", + "company_id": self.company.id, + "logs": [("info", "New Log")], + } + result_old = { + "log_type": "flow_download", + "company_id": self.company.id, + "logs": [("info", "Old Log")], + } + self.env["fr.einvoicing.log"]._create_log(result_new) + self.env["fr.einvoicing.log"]._create_log(result_old) + + logs = self.env["fr.einvoicing.log"].search([], order="id desc", limit=2) + log_new = logs[0] + log_old = logs[1] + + old_date = datetime.now() - timedelta(days=601) + self.env.cr.execute( + "UPDATE fr_einvoicing_log SET create_date = %s WHERE id = %s", + (old_date, log_old.id), + ) + self.env.invalidate_all() + + self.env["fr.einvoicing.log"]._gc_old_logs() + + self.assertTrue(log_new.exists()) + self.assertFalse(log_old.exists()) + + def test_gc_old_logs_uses_default_days_if_config_is_invalid(self): + self.env["ir.config_parameter"].sudo().set_param( + "fr_einvoicing.log_days", "invalid_integer" + ) + result_old = { + "log_type": "flow_download", + "company_id": self.company.id, + "logs": [("info", "Old Log")], + } + self.env["fr.einvoicing.log"]._create_log(result_old) + log_old = self.env["fr.einvoicing.log"].search([], limit=1) + + old_date = datetime.now() - timedelta(days=601) + self.env.cr.execute( + "UPDATE fr_einvoicing_log SET create_date = %s WHERE id = %s", + (old_date, log_old.id), + ) + self.env.invalidate_all() + + self.env["fr.einvoicing.log"]._gc_old_logs() + + self.assertFalse(log_old.exists()) diff --git a/l10n_fr_einvoicing/tests/test_fr_einvoicing_sync.py b/l10n_fr_einvoicing/tests/test_fr_einvoicing_sync.py new file mode 100644 index 0000000..7e26409 --- /dev/null +++ b/l10n_fr_einvoicing/tests/test_fr_einvoicing_sync.py @@ -0,0 +1,451 @@ +# Copyright 2026 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import contextlib +import datetime +from unittest.mock import MagicMock, patch + +from odoo.exceptions import UserError +from odoo.tools import config + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +class TestFrEinvoicingSync(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env["res.company"].create( + { + "name": "Test Company AP", + "country_id": cls.env.ref("base.fr").id, + "fr_ctc_accredited_platform": "superpdp", + "fr_ctc_auth_method": "client_credentials", + "fr_ctc_client_id": "test_client_id", + "fr_ctc_client_secret": "test_client_secret", + } + ) + cls.company.partner_id.write( + { + "siren": "120027016", + "country_id": cls.env.ref("base.fr").id, + "is_company": True, + } + ) + cls.partner = cls.env["res.partner"].create( + { + "name": "Test Partner AP", + "siren": "120027016", + "siret": "12002701600001", + "country_id": cls.env.ref("base.fr").id, + "is_company": True, + } + ) + + @contextlib.contextmanager + def _mock_cursor(self): + yield self.env.cr + + @patch("odoo.addons.l10n_fr_einvoicing.models.res_company.get_session") + @patch("odoo.addons.l10n_fr_einvoicing.models.res_company.healthcheck") + def test_test_api_success(self, mock_healthcheck, mock_get_session): + mock_healthcheck.return_value = True + mock_get_session.return_value = MagicMock() + + action = self.company._fr_ctc_test_api() + + self.assertEqual(action["params"]["type"], "success") + self.assertIn("Successful connection", action["params"]["message"]) + + @patch("odoo.addons.l10n_fr_einvoicing.models.res_company.get_session") + @patch("odoo.addons.l10n_fr_einvoicing.models.res_company.healthcheck") + def test_test_api_failure(self, mock_healthcheck, mock_get_session): + mock_get_session.return_value = MagicMock() + mock_healthcheck.side_effect = Exception("Connection Failed") + + with self.assertRaisesRegex(UserError, "Odoo failed to connect to the API"): + self.company._fr_ctc_test_api() + + @patch("odoo.addons.l10n_fr_einvoicing.models.res_company.get_authorization_url") + def test_authorization_code_redirect(self, mock_get_auth_url): + mock_get_auth_url.return_value = ( + "https://example.com/oauth", + "state123", + "verifier123", + ) + self.company.fr_ctc_auth_method = "authorization_code" + + with patch.dict( + config.options, {"fr_ctc_superpdp_client_id": "dummy_client_id"} + ): + action = self.company._fr_ctc_authorization_code_redirect() + + self.assertEqual(action["type"], "ir.actions.act_url") + self.assertEqual(action["url"], "https://example.com/oauth") + + def test_is_vat_registered_returns_false_for_non_france_company(self): + non_fr_company = self.env["res.company"].create( + { + "name": "Non FR Company", + "country_id": self.env.ref("base.us").id, + } + ) + + res = non_fr_company._fr_ctc_is_vat_registered() + + self.assertFalse(res) + + def test_is_vat_registered_raises_no_country(self): + non_country_company = self.env["res.company"].create( + { + "name": "No Country Company", + } + ) + with self.assertRaisesRegex(UserError, "Country is not set on company"): + non_country_company._fr_ctc_is_vat_registered(raise_if_misconfigured=True) + + def test_is_vat_registered_raises_no_entity_type(self): + self.company.partner_id.write( + { + "fr_directory_entity_type": False, + "fr_directory_last_sync_date": False, + } + ) + with self.assertRaisesRegex(UserError, "Entity type is not set on partner"): + self.company._fr_ctc_is_vat_registered(raise_if_misconfigured=True) + + def test_is_vat_registered_raises_public_entity_type(self): + self.company.partner_id.write( + { + "fr_directory_entity_type": "public", + "fr_directory_last_sync_date": datetime.date.today(), + } + ) + with self.assertRaisesRegex( + UserError, "is a public entity. This scenario is not supported" + ): + self.company._fr_ctc_is_vat_registered(raise_if_misconfigured=True) + + def test_is_vat_registered_raises_private_no_siren(self): + self.company.partner_id.write( + { + "fr_directory_entity_type": "private", + "fr_directory_last_sync_date": datetime.date.today(), + "siren": False, + } + ) + with self.assertRaisesRegex(UserError, "SIREN is not set on partner"): + self.company._fr_ctc_is_vat_registered(raise_if_misconfigured=True) + + def test_fr_ctc_credentials_raises_no_platform(self): + self.company.fr_ctc_accredited_platform = False + with self.assertRaisesRegex( + UserError, "No accredited platform selected for company" + ): + self.company._fr_ctc_credentials() + + def test_fr_ctc_credentials_raises_no_auth_method(self): + self.company.fr_ctc_auth_method = False + with self.assertRaisesRegex( + UserError, + "The authentication method for the accredited platform is not configured", + ): + self.company._fr_ctc_credentials() + + def test_fr_ctc_credentials_raises_client_credentials_no_client_id(self): + self.company.write( + { + "fr_ctc_auth_method": "client_credentials", + "fr_ctc_client_id": False, + } + ) + with self.assertRaisesRegex( + UserError, "The Client ID of the accredited platform is not configured" + ): + self.company._fr_ctc_credentials() + + def test_fr_ctc_credentials_raises_client_credentials_no_client_secret(self): + self.company.write( + { + "fr_ctc_auth_method": "client_credentials", + "fr_ctc_client_secret": False, + } + ) + with self.assertRaisesRegex( + UserError, "The Client Secret of the accredited platform is not configured" + ): + self.company._fr_ctc_credentials() + + def test_fr_ctc_credentials_raises_authorization_code_no_client_id_config(self): + self.company.write( + { + "fr_ctc_auth_method": "authorization_code", + } + ) + with patch.dict(config.options, {"fr_ctc_superpdp_client_id": ""}): + with self.assertRaisesRegex( + UserError, + ( + "Missing key 'fr_ctc_superpdp_client_id' " + "in the Odoo server configuration file" + ), + ): + self.company._fr_ctc_credentials() + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.get_directory_siren_parsed" + ) + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.get_directory_lines_parsed" + ) + def test_partner_directory_sync_private_company( + self, mock_get_lines, mock_get_siren + ): + mock_get_siren.return_value = { + "entity_type": "private", + "closed": False, + "name": "Test Partner AP Clean", + } + mock_get_lines.return_value = { + "120027016": { + "state": "active", + "routing_code_name": "ROUTING_A", + "commitment_required": False, + "type": "siren", + } + } + mock_session = MagicMock() + + self.partner._fr_directory_sync( + mock_session, result={"logs": [], "new_count": 0, "updated_count": 0} + ) + + self.assertEqual(self.partner.fr_directory_entity_type, "private") + self.assertEqual(self.partner.fr_directory_name, "Test Partner AP Clean") + self.assertFalse(self.partner.fr_directory_closed) + self.assertEqual(len(self.partner.fr_directory_line_ids), 1) + self.assertEqual(self.partner.fr_directory_line_ids[0].identifier, "120027016") + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.get_directory_siren_parsed" + ) + def test_partner_directory_sync_closed_company(self, mock_get_siren): + mock_get_siren.return_value = { + "entity_type": "private", + "closed": True, + "name": "Test Partner Closed", + } + mock_session = MagicMock() + + self.partner._fr_directory_sync( + mock_session, result={"logs": [], "new_count": 0, "updated_count": 0} + ) + + self.assertEqual(self.partner.fr_directory_entity_type, "private") + self.assertTrue(self.partner.fr_directory_closed) + + def test_token_get_and_write(self): + with patch.object(self.env.registry, "cursor", side_effect=self._mock_cursor): + self.company._fr_ctc_write_token( + { + "access_token": "abc_access", + "refresh_token": "abc_refresh", + "expires_at": 123456789.0, + } + ) + token = self.company._fr_ctc_get_token("authorization_code") + + self.assertEqual(token["access_token"], "abc_access") + self.assertEqual(token["refresh_token"], "abc_refresh") + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany.fr_ctc_run_import_log" + ) + def test_run_import_log_action_returns_correct_action(self, mock_run_import_log): + result = { + "new_count": 2, + "warning_count": 1, + "error_count": 0, + } + mock_run_import_log.return_value = (None, result) + action = self.company.fr_ctc_run_import_log_action("Test Origin") + + self.assertEqual(action["type"], "ir.actions.client") + self.assertEqual(action["params"]["type"], "warning") + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.get_directory_siren_parsed" + ) + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.get_directory_siret_parsed" + ) + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.get_directory_lines_parsed" + ) + def test_partner_directory_sync_public_entity( + self, mock_get_lines, mock_get_siret, mock_get_siren + ): + mock_get_siren.return_value = { + "entity_type": "public", + "closed": False, + "name": "Public Entity AP", + } + mock_get_siret.return_value = { + "closed": False, + "name": "Public Branch AP", + } + mock_get_lines.return_value = { + "12002701600001": { + "state": "active", + "routing_code_name": "ROUTING_B", + "commitment_required": True, + "type": "siret", + } + } + mock_session = MagicMock() + + self.partner._fr_directory_sync( + mock_session, result={"logs": [], "new_count": 0, "updated_count": 0} + ) + self.assertEqual(self.partner.fr_directory_entity_type, "public") + self.assertEqual(self.partner.fr_directory_name, "Public Branch AP") + self.assertEqual(len(self.partner.fr_directory_line_ids), 1) + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.get_directory_siren_parsed" + ) + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.get_directory_lines_parsed" + ) + def test_partner_directory_sync_button( + self, mock_get_lines, mock_get_siren, mock_get_session + ): + mock_get_siren.return_value = { + "entity_type": "private", + "closed": False, + "name": "Sync Button Partner", + } + mock_get_lines.return_value = {} + + action = self.partner.fr_directory_sync_button() + self.assertEqual(action["type"], "ir.actions.client") + self.assertEqual(action["params"]["type"], "success") + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.get_directory_siren_parsed" + ) + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.get_directory_lines_parsed" + ) + def test_partner_directory_sync_cron( + self, mock_get_lines, mock_get_siren, mock_get_session + ): + mock_get_siren.return_value = { + "entity_type": "private", + "closed": False, + "name": "Cron Sync Partner", + } + mock_get_lines.return_value = {} + + self.partner.write( + { + "fr_directory_entity_type": "private", + "fr_directory_closed": False, + "fr_directory_last_sync_date": datetime.date.today() + - datetime.timedelta(days=100), + } + ) + + self.env["res.partner"]._fr_directory_sync_cron() + self.assertEqual(self.partner.fr_directory_name, "Cron Sync Partner") + + @patch("odoo.addons.l10n_fr_einvoicing.models.res_company.get_session") + @patch("odoo.addons.l10n_fr_einvoicing.models.res_company.search_flows_parsed") + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_prepare_in_flow" + ) + def test_fr_ctc_run_import_log_real( + self, mock_prepare_in_flow, mock_search_flows, mock_get_session + ): + mock_get_session.return_value = MagicMock() + mock_search_flows.return_value = [ + { + "flowId": "flow_in_999", + "flowSyntax": "UBL", + "flowType": "SupplierInvoice", + "processingRule": "B2B", + "flow_direction": "in", + "type": "SupplierInvoice", + } + ] + mock_prepare_in_flow.return_value = { + "direction": "in", + "identifier": "flow_in_999", + "syntax": "UBL", + "type": "SupplierInvoice", + "processing_rule": "B2B", + "company_id": self.company.id, + } + + with ( + patch( + "odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.FrEinvoicingFlow._download" + ) as mock_download, + patch( + "odoo.addons.l10n_fr_einvoicing.models.fr_einvoicing_flow.FrEinvoicingFlow._process" + ), + ): + flows, result = self.company.fr_ctc_run_import_log("Cron Origin") + + self.assertEqual(len(flows), 1) + self.assertEqual(flows.identifier, "flow_in_999") + self.assertEqual(result["new_count"], 1) + mock_download.assert_called_once() + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.get_directory_siren_parsed" + ) + def test_partner_directory_sync_not_in_directory( + self, mock_get_siren, mock_get_session + ): + mock_get_siren.return_value = { + "entity_type": "no", + "closed": False, + "name": "Unknown Partner", + } + action = self.partner.fr_directory_sync_button() + self.assertEqual(action["params"]["type"], "warning") + self.assertIn("is not in the directory", action["params"]["message"]) + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_get_session" + ) + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.get_directory_siren_parsed" + ) + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_partner.get_directory_lines_parsed" + ) + def test_partner_directory_sync_lines_failure( + self, mock_get_lines, mock_get_siren, mock_get_session + ): + mock_get_siren.return_value = { + "entity_type": "private", + "closed": False, + "name": "Some Partner", + } + mock_get_lines.side_effect = Exception("API connection timed out") + with self.assertRaisesRegex( + UserError, "Failed to query directory with SIREN or SIRET" + ): + self.partner.fr_directory_sync_button() diff --git a/l10n_fr_einvoicing/tests/test_res_config_settings.py b/l10n_fr_einvoicing/tests/test_res_config_settings.py new file mode 100644 index 0000000..ed2007c --- /dev/null +++ b/l10n_fr_einvoicing/tests/test_res_config_settings.py @@ -0,0 +1,30 @@ +# Copyright 2026 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +class TestResConfigSettings(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.wizard = cls.env["res.config.settings"].create({}) + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_test_api" + ) + def test_fr_ctc_test_api_button(self, mock_test_api): + mock_test_api.return_value = {"type": "ir.actions.client"} + res = self.wizard.fr_ctc_test_api_button() + self.assertEqual(res, {"type": "ir.actions.client"}) + + @patch( + "odoo.addons.l10n_fr_einvoicing.models.res_company.ResCompany._fr_ctc_authorization_code_redirect" + ) + def test_fr_ctc_authorization_code_onboarding(self, mock_redirect): + mock_redirect.return_value = {"type": "ir.actions.act_url"} + res = self.wizard.fr_ctc_authorization_code_onboarding() + self.assertEqual(res, {"type": "ir.actions.act_url"}) diff --git a/l10n_fr_einvoicing/tests/test_res_partner.py b/l10n_fr_einvoicing/tests/test_res_partner.py new file mode 100644 index 0000000..c665135 --- /dev/null +++ b/l10n_fr_einvoicing/tests/test_res_partner.py @@ -0,0 +1,209 @@ +# Copyright 2026 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.exceptions import ValidationError + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +class TestResPartner(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.france = cls.env.ref("base.fr") + cls.partner = cls.env["res.partner"].create( + { + "name": "Test Partner ResPartner", + "country_id": cls.france.id, + "is_company": True, + "siren": "120027016", + "siret": "12002701600001", + } + ) + + def test_fr_directory_line_show_when_private_or_public(self): + self.partner.write( + { + "fr_directory_entity_type": "private", + "fr_directory_closed": False, + "fr_directory_last_sync_date": "2026-06-23", + } + ) + self.partner._compute_fr_directory_line_show() + self.assertTrue(self.partner.fr_directory_line_show) + + self.partner.write( + { + "fr_directory_closed": True, + } + ) + self.partner._compute_fr_directory_line_show() + self.assertFalse(self.partner.fr_directory_line_show) + + def test_fr_directory_line_active_count_increments_on_active_lines(self): + self.partner.write( + { + "fr_directory_entity_type": "private", + "fr_directory_closed": False, + "fr_directory_last_sync_date": "2026-06-23", + } + ) + + self.partner._compute_fr_directory_line_active_count() + self.assertEqual(self.partner.fr_directory_line_active_count, 0) + + self.env["fr.directory.line"].create( + { + "partner_id": self.partner.id, + "identifier": "123456789", + "type": "siren", + "state": "active", + } + ) + self.partner._compute_fr_directory_line_active_count() + self.assertEqual(self.partner.fr_directory_line_active_count, 1) + + def test_fr_directory_entity_changed_warning_triggers_on_siren_or_siret_diff(self): + self.partner.write( + { + "fr_directory_entity_type": "public", + "fr_directory_siret": "12002701600001", + "fr_directory_last_sync_date": "2026-06-23", + "siret": "12002701600027", + } + ) + self.partner._compute_fr_directory_entity_changed_warning() + self.assertIsNotNone(self.partner.fr_directory_entity_changed_warning) + + self.partner.write( + { + "fr_directory_entity_type": "private", + "fr_directory_siren": "120027016", + "fr_directory_last_sync_date": "2026-06-23", + "siret": "55208131700018", + } + ) + self.partner._compute_fr_directory_entity_changed_warning() + self.assertIsNotNone(self.partner.fr_directory_entity_changed_warning) + + def test_fr_directory_show_warning_missing_siren_on_french_company(self): + # We need to use another partner to avoid validation checks on + # siren/siret fields of self.partner + tmp_partner = self.env["res.partner"].create( + { + "name": "Tmp Partner", + "is_company": True, + "parent_id": False, + "vat": False, + "siren": False, + "country_id": self.france.id, + } + ) + tmp_partner._compute_fr_directory_show_warning_missing_siren() + self.assertTrue(tmp_partner.fr_directory_show_warning_missing_siren) + + def test_fr_directory_reset_parent_clears_fields_when_parent_is_set(self): + parent = self.env["res.partner"].create({"name": "Parent Partner"}) + self.partner.write( + { + "fr_directory_entity_type": "private", + "fr_directory_closed": True, + "fr_directory_name": "Test Name", + "fr_directory_last_sync_date": "2026-06-23", + "parent_id": parent.id, + } + ) + self.partner._compute_fr_directory_reset_parent() + self.assertFalse(self.partner.fr_directory_entity_type) + self.assertFalse(self.partner.fr_directory_closed) + self.assertFalse(self.partner.fr_directory_name) + self.assertFalse(self.partner.fr_directory_last_sync_date) + + def test_fr_directory_check_raises_validation_error_on_missing_sync_date(self): + with self.assertRaises(ValidationError): + self.env["res.partner"].create( + { + "name": "Invalid Partner", + "fr_directory_entity_type": "private", + "fr_directory_last_sync_date": False, + } + ) + + def test_fr_directory_confirm_common_checks_returns_error_if_closed(self): + self.partner.write( + { + "fr_directory_entity_type": "private", + "fr_directory_closed": True, + "fr_directory_last_sync_date": "2026-06-23", + } + ) + err = self.partner._fr_directory_confirm_common_checks() + self.assertIn("is marked as closed", err) + + def test_fr_directory_siren_change_error_returns_error_on_mismatch(self): + self.partner.write( + { + "siret": "55208131700018", + "fr_directory_siren": "120027016", + } + ) + err = self.partner._fr_directory_siren_change_error() + self.assertIn("SIREN currently configured", err) + + def test_fr_directory_siret_change_error_returns_error_on_mismatch(self): + self.partner.write( + { + "siret": "12002701600027", + "fr_directory_siret": "12002701600001", + } + ) + err = self.partner._fr_directory_siret_change_error() + self.assertIn("SIRET currently configured", err) + + def test_fr_directory_sync_if_old_get_days_invalid_and_negative(self): + self.env["ir.config_parameter"].set_param( + "fr_directory.update_partner_if_older_than_days", "invalid_value" + ) + self.env["ir.config_parameter"].set_param( + "fr_directory.update_private_inactive_partner_if_older_than_days", "-10" + ) + + result = {"logs": [], "new_count": 0, "updated_count": 0} + days = self.env["res.partner"]._fr_directory_sync_if_old_get_days(result) + # DEFAULT_UPDATE_PARTNER_IF_OLDER_THAN_DAYS = 30 + # DEFAULT_UPDATE_PRIVATE_INACTIVE_PARTNER_IF_OLDER_THAN_DAYS = 5 + self.assertEqual(days[0], 30) + self.assertEqual(days[1], 5) + self.assertTrue( + any( + "Failed to convert ir.config_parameter" in log[1] + for log in result["logs"] + ) + ) + self.assertTrue(any("is negative" in log[1] for log in result["logs"])) + + def test_fr_directory_confirm_common_checks_returns_none(self): + self.partner.write( + { + "fr_directory_entity_type": "private", + "fr_directory_closed": False, + "fr_directory_last_sync_date": "2026-06-23", + "fr_directory_siren": "120027016", + } + ) + self.assertIsNone(self.partner._fr_directory_confirm_common_checks()) + + # Also cover no-change paths for siret/siren error methods directly + self.assertIsNone(self.partner._fr_directory_siren_change_error("120027016")) + self.assertIsNone( + self.partner._fr_directory_siret_change_error("12002701600001") + ) + + self.partner.write( + { + "fr_directory_entity_type": "public", + "fr_directory_siret": "12002701600001", + } + ) + self.assertIsNone(self.partner._fr_directory_confirm_common_checks())