diff --git a/account_invoice_import_ubl/tests/__init__.py b/account_invoice_import_ubl/tests/__init__.py index 40a06956c0..e26b9c37c7 100644 --- a/account_invoice_import_ubl/tests/__init__.py +++ b/account_invoice_import_ubl/tests/__init__.py @@ -1 +1,2 @@ from . import test_ubl +from . import test_ubl_price_unit diff --git a/account_invoice_import_ubl/tests/files/UBLInvoice-price-unit.xml b/account_invoice_import_ubl/tests/files/UBLInvoice-price-unit.xml new file mode 100644 index 0000000000..c46eb60737 --- /dev/null +++ b/account_invoice_import_ubl/tests/files/UBLInvoice-price-unit.xml @@ -0,0 +1,105 @@ + + + urn:cen.eu:en16931:2017 + SAMPLE-PRICE-001 + 2026-09-03 + 2026-10-03 + 380 + EUR + + + + Sample Hosting Provider + + + 2 rue de l'Exemple + ROUBAIX + 59100 + + FR + + + + FR00000000000 + + VAT + + + + + + + + Sample Customer + + + + + 50.45 + + + 252.25 + 252.25 + 302.70 + 302.70 + + + 1 + 1 + 154.20 + + Gateway - general + + S + 20 + + VAT + + + + + 0.00 + + + + 2 + 500 + 50.00 + + Metered storage + + S + 20 + + VAT + + + + + 10.00 + 100 + + + + 3 + 1 + 48.05 + + Monthly instance fee + + S + 20 + + VAT + + + + + 48.05 + + + diff --git a/account_invoice_import_ubl/tests/test_ubl_price_unit.py b/account_invoice_import_ubl/tests/test_ubl_price_unit.py new file mode 100644 index 0000000000..34028ca68d --- /dev/null +++ b/account_invoice_import_ubl/tests/test_ubl_price_unit.py @@ -0,0 +1,43 @@ +# Copyright 2026 teamDSI +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 + +from odoo.tests.common import TransactionCase +from odoo.tools import file_open + + +class TestUblPriceUnit(TransactionCase): + """The unit price must reproduce the net amount of the line (BT-131).""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.ref("base.main_company") + + def _parse(self, filename): + f = file_open("account_invoice_import_ubl/tests/files/" + filename, "rb") + content = f.read() + f.close() + return self.env["account.invoice.import"].parse_invoice( + base64.b64encode(content), filename, self.company + ) + + def test_price_unit_recomputed_when_inconsistent(self): + parsed = self._parse("UBLInvoice-price-unit.xml") + lines = parsed["lines"] + self.assertEqual(len(lines), 3) + # cbc:PriceAmount is 0.00 while the line carries 154.20: without the + # fallback the line is imported at zero and doubled by an adjustment + # line. + self.assertAlmostEqual(lines[0]["price_unit"], 154.20, places=2) + # The price is quoted for a base quantity of 100 (BT-149). + self.assertAlmostEqual(lines[1]["price_unit"], 0.10, places=2) + # The file is consistent: the price it carries is kept as-is. + self.assertAlmostEqual(lines[2]["price_unit"], 48.05, places=2) + # In all three cases the lines add up to the declared BT-106. + self.assertAlmostEqual( + sum(line["qty"] * line["price_unit"] for line in lines), + parsed["amount_untaxed"], + places=2, + ) diff --git a/account_invoice_import_ubl/wizard/account_invoice_import.py b/account_invoice_import_ubl/wizard/account_invoice_import.py index ad5503d06a..76b51dcd38 100644 --- a/account_invoice_import_ubl/wizard/account_invoice_import.py +++ b/account_invoice_import_ubl/wizard/account_invoice_import.py @@ -9,6 +9,7 @@ from odoo import _, api, fields, models from odoo.exceptions import UserError +from odoo.tools import float_is_zero logger = logging.getLogger(__name__) @@ -72,9 +73,25 @@ def parse_ubl_invoice_line(self, iline, counters, namespaces): price_subtotal = float(price_subtotal_xpath[0].text) if not price_subtotal: return False + price_unit = None if price_unit_xpath: price_unit = float(price_unit_xpath[0].text) - else: + # BT-149 (cbc:BaseQuantity): BT-146 may be quoted for a quantity + # other than one, in which case the unit price is the ratio. + base_qty_xpath = iline.xpath( + "cac:Price/cbc:BaseQuantity", namespaces=namespaces + ) + if base_qty_xpath and float(base_qty_xpath[0].text): + price_unit /= float(base_qty_xpath[0].text) + # Some issuers send a unit price that does not reproduce the net + # amount of the line: a plain 0.00, or a price that ignores a line + # level allowance. BT-131 (cbc:LineExtensionAmount) is the + # authoritative amount, so recompute the unit price from it rather + # than import a line whose amount is wrong and leave + # _post_process_invoice() to patch it with an adjustment line. + if not float_is_zero(price_unit * qty - price_subtotal, precision_digits=2): + price_unit = None + if price_unit is None: price_unit = price_subtotal / qty counters["lines"] += price_subtotal taxes_xpath = iline.xpath(