Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions account_invoice_import_ubl/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from . import test_ubl
from . import test_ubl_price_unit
105 changes: 105 additions & 0 deletions account_invoice_import_ubl/tests/files/UBLInvoice-price-unit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Invoice
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
>
<cbc:CustomizationID>urn:cen.eu:en16931:2017</cbc:CustomizationID>
<cbc:ID>SAMPLE-PRICE-001</cbc:ID>
<cbc:IssueDate>2026-09-03</cbc:IssueDate>
<cbc:DueDate>2026-10-03</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyName>
<cbc:Name>Sample Hosting Provider</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>2 rue de l'Exemple</cbc:StreetName>
<cbc:CityName>ROUBAIX</cbc:CityName>
<cbc:PostalZone>59100</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>FR</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>FR00000000000</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyName>
<cbc:Name>Sample Customer</cbc:Name>
</cac:PartyName>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="EUR">50.45</cbc:TaxAmount>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="EUR">252.25</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="EUR">252.25</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="EUR">302.70</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="EUR">302.70</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="C62">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">154.20</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Gateway - general</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>20</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">0.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>2</cbc:ID>
<cbc:InvoicedQuantity unitCode="C62">500</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">50.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Metered storage</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>20</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">10.00</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="C62">100</cbc:BaseQuantity>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>3</cbc:ID>
<cbc:InvoicedQuantity unitCode="C62">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">48.05</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Monthly instance fee</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>20</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">48.05</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
43 changes: 43 additions & 0 deletions account_invoice_import_ubl/tests/test_ubl_price_unit.py
Original file line number Diff line number Diff line change
@@ -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,
)
19 changes: 18 additions & 1 deletion account_invoice_import_ubl/wizard/account_invoice_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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(
Expand Down
Loading