diff --git a/addons/account_edi_ubl_cii/data/cii_22_templates.xml b/addons/account_edi_ubl_cii/data/cii_22_templates.xml index b1685a33467..f890cb6b5e6 100644 --- a/addons/account_edi_ubl_cii/data/cii_22_templates.xml +++ b/addons/account_edi_ubl_cii/data/cii_22_templates.xml @@ -31,9 +31,12 @@ - + - + @@ -66,17 +69,6 @@ - - - - - false - - - - 95 - - diff --git a/addons/account_edi_ubl_cii/models/account_edi_common.py b/addons/account_edi_ubl_cii/models/account_edi_common.py index 6108a305ed2..ad8f29e9db7 100644 --- a/addons/account_edi_ubl_cii/models/account_edi_common.py +++ b/addons/account_edi_ubl_cii/models/account_edi_common.py @@ -408,7 +408,7 @@ class AccountEdiCommon(models.AbstractModel): with (UBL | CII): * net_unit_price = 'Price/PriceAmount' | 'NetPriceProductTradePrice' (mandatory) (BT-146) - * gross_unit_price = 'GrossPriceProductTradePrice' | 'GrossPriceProductTradePrice' (optional) (BT-148) + * gross_unit_price = 'Price/AllowanceCharge/BaseAmount' | 'GrossPriceProductTradePrice' (optional) (BT-148) * basis_qty = 'Price/BaseQuantity' | 'BasisQuantity' (optional, either below net_price node or gross_price node) (BT-149) * billed_qty = 'InvoicedQuantity' | 'BilledQuantity' (mandatory) (BT-129) @@ -450,6 +450,12 @@ class AccountEdiCommon(models.AbstractModel): } :params: invoice_line :params: qty_factor + :returns: { + 'quantity': float, + 'product_uom_id': (optional) uom.uom, + 'price_unit': float, + 'discount': float, + } """ # basis_qty (optional) basis_qty = 1 @@ -471,10 +477,9 @@ class AccountEdiCommon(models.AbstractModel): rebate_node = tree.find(xpath_dict['rebate']) net_price_unit_node = tree.find(xpath_dict['net_price_unit']) if rebate_node is not None: - if net_price_unit_node is not None and gross_price_unit_node is not None: - rebate = float(gross_price_unit_node.text) - float(net_price_unit_node.text) - else: - rebate = float(rebate_node.text) + rebate = float(rebate_node.text) + elif net_price_unit_node is not None and gross_price_unit_node is not None: + rebate = float(gross_price_unit_node.text) - float(net_price_unit_node.text) # net_price_unit (mandatory) net_price_unit = None @@ -519,9 +524,7 @@ class AccountEdiCommon(models.AbstractModel): #################################################### # quantity - invoice_line.quantity = billed_qty * qty_factor - if product_uom_id is not None: - invoice_line.product_uom_id = product_uom_id + quantity = billed_qty * qty_factor # price_unit if gross_price_unit is not None: @@ -530,17 +533,57 @@ class AccountEdiCommon(models.AbstractModel): price_unit = (net_price_unit + rebate) / basis_qty else: raise UserError(_("No gross price nor net price found for line in xml")) - invoice_line.price_unit = price_unit # discount + discount = 0 if billed_qty * price_unit != 0 and price_subtotal is not None: - invoice_line.discount = 100 * (1 - price_subtotal / (billed_qty * price_unit)) + discount = 100 * (1 - price_subtotal / (billed_qty * price_unit)) # Sometimes, the xml received is very bad: unit price = 0, qty = 1, but price_subtotal = -200 # for instance, when filling a down payment as an invoice line. The equation in the docstring is not # respected, and the result will not be correct, so we just follow the simple rule below: if net_price_unit == 0 and price_subtotal != net_price_unit * (billed_qty / basis_qty) - allow_charge_amount: - invoice_line.price_unit = price_subtotal / billed_qty + price_unit = price_subtotal / billed_qty + + return { + 'quantity': quantity, + 'price_unit': price_unit, + 'discount': discount, + 'product_uom_id': product_uom_id, + } + + def _import_fill_invoice_line_taxes(self, journal, tax_nodes, invoice_line_form, inv_line_vals, logs): + # Taxes: all amounts are tax excluded, so first try to fetch price_include=False taxes, + # if no results, try to fetch the price_include=True taxes. If results, need to adapt the price_unit. + inv_line_vals['taxes'] = [] + for tax_node in tax_nodes: + amount = float(tax_node.text) + domain = [ + ('company_id', '=', journal.company_id.id), + ('amount_type', '=', 'percent'), + ('type_tax_use', '=', journal.type), + ('amount', '=', amount), + ] + tax_excl = self.env['account.tax'].search(domain + [('price_include', '=', False)], limit=1) + tax_incl = self.env['account.tax'].search(domain + [('price_include', '=', True)], limit=1) + if tax_excl: + inv_line_vals['taxes'].append(tax_excl.id) + elif tax_incl: + inv_line_vals['taxes'].append(tax_incl.id) + inv_line_vals['price_unit'] *= (1 + tax_incl.amount / 100) + else: + logs.append(_("Could not retrieve the tax: %s %% for line '%s'.", amount, invoice_line_form.name)) + # Set the values on the line_form + invoice_line_form.quantity = inv_line_vals['quantity'] + if inv_line_vals.get('product_uom_id'): + invoice_line_form.product_uom_id = inv_line_vals['product_uom_id'] + else: + logs.append( + _("Could not retrieve the unit of measure for line with label '%s'.", invoice_line_form.name)) + invoice_line_form.price_unit = inv_line_vals['price_unit'] + invoice_line_form.discount = inv_line_vals['discount'] + invoice_line_form.tax_ids = inv_line_vals['taxes'] + return logs # ------------------------------------------------------------------------- # Check xml using the free API from Ph. Helger, don't abuse it ! diff --git a/addons/account_edi_ubl_cii/models/account_edi_xml_cii_facturx.py b/addons/account_edi_ubl_cii/models/account_edi_xml_cii_facturx.py index b5d9f00aedf..32fb0ba4def 100644 --- a/addons/account_edi_ubl_cii/models/account_edi_xml_cii_facturx.py +++ b/addons/account_edi_ubl_cii/models/account_edi_xml_cii_facturx.py @@ -332,30 +332,10 @@ class AccountEdiXmlCII(models.AbstractModel): 'allowance_charge_amount': './{*}ActualAmount', # below allowance_charge node 'line_total_amount': './{*}SpecifiedLineTradeSettlement/{*}SpecifiedTradeSettlementLineMonetarySummation/{*}LineTotalAmount', } - self._import_fill_invoice_line_values(tree, xpath_dict, invoice_line, qty_factor) - - if not invoice_line.product_uom_id: - logs.append( - _("Could not retrieve the unit of measure for line with label '%s'. Did you install the inventory " - "app and enabled the 'Units of Measure' option ?", invoice_line.name)) - - # Taxes - taxes = self.env['account.tax'] + inv_line_vals = self._import_fill_invoice_line_values(tree, xpath_dict, invoice_line, qty_factor) + # retrieve tax nodes tax_nodes = tree.findall('.//{*}ApplicableTradeTax/{*}RateApplicablePercent') - for tax_node in tax_nodes: - tax = self.env['account.tax'].search([ - ('company_id', '=', journal.company_id.id), - ('amount', '=', float(tax_node.text)), - ('amount_type', '=', 'percent'), - ('type_tax_use', '=', 'purchase'), - ], limit=1) - if tax: - taxes += tax - else: - logs.append(_("Could not retrieve the tax: %s %% for line '%s'.", float(tax_node.text), invoice_line.name)) - - invoice_line.tax_ids = taxes - return logs + return self._import_fill_invoice_line_taxes(journal, tax_nodes, invoice_line, inv_line_vals, logs) # ------------------------------------------------------------------------- # IMPORT : helpers diff --git a/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py b/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py index bcc0eb4c7b5..1a2d6594ba4 100644 --- a/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py +++ b/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py @@ -586,33 +586,14 @@ class AccountEdiXmlUBL20(models.AbstractModel): } self._import_fill_invoice_line_values(tree, xpath_dict, invoice_line, qty_factor) - if not invoice_line.product_uom_id: - logs.append( - _("Could not retrieve the unit of measure for line with label '%s'. Did you install the inventory " - "app and enabled the 'Units of Measure' option ?", invoice_line.name)) - # Taxes - taxes = self.env['account.tax'] - + inv_line_vals = self._import_fill_invoice_line_values(tree, xpath_dict, invoice_line, qty_factor) + # retrieve tax nodes tax_nodes = tree.findall('.//{*}Item/{*}ClassifiedTaxCategory/{*}Percent') if not tax_nodes: for elem in tree.findall('.//{*}TaxTotal'): tax_nodes += elem.findall('.//{*}TaxSubtotal/{*}Percent') - - for tax_node in tax_nodes: - tax = self.env['account.tax'].search([ - ('company_id', '=', journal.company_id.id), - ('amount', '=', float(tax_node.text)), - ('amount_type', '=', 'percent'), - ('type_tax_use', '=', 'purchase'), - ], limit=1) - if tax: - taxes += tax - else: - logs.append(_("Could not retrieve the tax: %s %% for line '%s'.", float(tax_node.text), invoice_line.name)) - - invoice_line.tax_ids = taxes - return logs + return self._import_fill_invoice_line_taxes(journal, tax_nodes, invoice_line, inv_line_vals, logs) # ------------------------------------------------------------------------- # IMPORT : helpers diff --git a/addons/l10n_account_edi_ubl_cii_tests/tests/common.py b/addons/l10n_account_edi_ubl_cii_tests/tests/common.py index f9d63c7d05d..6663dd18665 100644 --- a/addons/l10n_account_edi_ubl_cii_tests/tests/common.py +++ b/addons/l10n_account_edi_ubl_cii_tests/tests/common.py @@ -81,6 +81,7 @@ class TestUBLCommon(AccountEdiTestCommon): self.assert_same_invoice(invoice, new_invoice) def _assert_imported_invoice_from_file(self, subfolder, filename, amount_total, amount_tax, list_line_subtotals, + list_line_price_unit=None, list_line_discount=None, list_line_taxes=None, move_type='in_invoice', currency_id=None): """ Create an empty account.move, update the file to fill its fields, asserts the currency, total and tax amounts @@ -113,6 +114,16 @@ class TestUBLCommon(AccountEdiTestCommon): Counter(invoice.invoice_line_ids.mapped('price_subtotal')), Counter(list_line_subtotals), ) + if list_line_price_unit: + self.assertEqual(invoice.invoice_line_ids.mapped('price_unit'), list_line_price_unit) + if list_line_discount: + # See test_import_tax_included: sometimes, it's impossible to retrieve the exact discount at import because + # of rounding during export. The obtained discount might be 10.001 while the expected is 10. + dp = self.env.ref('product.decimal_discount').precision_get("Discount") + self.assertEqual([round(d, dp) for d in invoice.invoice_line_ids.mapped('discount')], list_line_discount) + if list_line_taxes: + for line, taxes in zip(invoice.invoice_line_ids, list_line_taxes): + self.assertEqual(line.tax_ids, taxes) # ------------------------------------------------------------------------- # EXPORT HELPERS diff --git a/addons/l10n_account_edi_ubl_cii_tests/tests/test_files/from_odoo/facturx_out_invoice.xml b/addons/l10n_account_edi_ubl_cii_tests/tests/test_files/from_odoo/facturx_out_invoice.xml index 8c7f1a242ba..7fa151c8f3e 100644 --- a/addons/l10n_account_edi_ubl_cii_tests/tests/test_files/from_odoo/facturx_out_invoice.xml +++ b/addons/l10n_account_edi_ubl_cii_tests/tests/test_files/from_odoo/facturx_out_invoice.xml @@ -45,13 +45,6 @@ S 21.0 - - - false - - 99.00 - 95 - 1782.00 diff --git a/addons/l10n_account_edi_ubl_cii_tests/tests/test_files/from_odoo/facturx_out_invoice_tax_incl.xml b/addons/l10n_account_edi_ubl_cii_tests/tests/test_files/from_odoo/facturx_out_invoice_tax_incl.xml new file mode 100644 index 00000000000..ae2eef40989 --- /dev/null +++ b/addons/l10n_account_edi_ubl_cii_tests/tests/test_files/from_odoo/facturx_out_invoice_tax_incl.xml @@ -0,0 +1,239 @@ + + + + urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended + + + + INV/2017/01/0001 + 380 + + 20170101 + + + test narration + + + + + + 1 + + + product_a + + + + 95.24 + + + 95.24 + + + + 1.0 + + + + VAT + S + 5.0 + + + 95.24 + + + + + + 2 + + + product_a + + + + 100.00 + + + 100.00 + + + + 1.0 + + + + VAT + S + 5.0 + + + 100.00 + + + + + + 3 + + + product_a + + + + 190.48 + + + false + + 19.05 + + + + 171.43 + + + + 1.0 + + + + VAT + S + 5.0 + + + 171.43 + + + + + + 4 + + + product_a + + + + 200.00 + + + false + + 20.00 + + + + 180.00 + + + + 1.0 + + + + VAT + S + 5.0 + + + 180.00 + + + + + + partner_1 + + partner_1 + + +1 (650) 555-0111 + + + partner1@yourcompany.com + + + + 75000 + Rue Jean Jaurès, 42 + Paris + FR + + + FR05677404089 + + + + partner_2 + + partner_2 + + + 52330 + Rue Charles de Gaulle + Colombey-les-Deux-Églises + FR + + + FR35562153452 + + + + INV/2017/01/0001 + + + + + partner_2 + + partner_2 + + + 52330 + Rue Charles de Gaulle + Colombey-les-Deux-Églises + FR + + + + + 20170101 + + + + + INV/2017/00001 + USD + + 42 + + FR15001559627230 + + + + 27.33 + VAT + 546.67 + S + 5 + 5.0 + + + 30% Advance End of Following Month + + 20170228 + + + + 546.67 + 546.67 + 27.33 + 574.00 + 0.00 + 574.00 + + + + diff --git a/addons/l10n_account_edi_ubl_cii_tests/tests/test_files/from_odoo/facturx_out_refund.xml b/addons/l10n_account_edi_ubl_cii_tests/tests/test_files/from_odoo/facturx_out_refund.xml index d1ff9a33470..1c4ed46bcbd 100644 --- a/addons/l10n_account_edi_ubl_cii_tests/tests/test_files/from_odoo/facturx_out_refund.xml +++ b/addons/l10n_account_edi_ubl_cii_tests/tests/test_files/from_odoo/facturx_out_refund.xml @@ -45,13 +45,6 @@ S 21.0 - - - false - - 99.00 - 95 - 1782.00 diff --git a/addons/l10n_account_edi_ubl_cii_tests/tests/test_xml_cii_fr.py b/addons/l10n_account_edi_ubl_cii_tests/tests/test_xml_cii_fr.py index f7211cf84d4..2795414946c 100644 --- a/addons/l10n_account_edi_ubl_cii_tests/tests/test_xml_cii_fr.py +++ b/addons/l10n_account_edi_ubl_cii_tests/tests/test_xml_cii_fr.py @@ -69,6 +69,35 @@ class TestCIIFR(TestUBLCommon): 'country_id': cls.env.ref('base.fr').id, }) + cls.tax_5_purchase = cls.env['account.tax'].create({ + 'name': 'tax_5', + 'amount_type': 'percent', + 'amount': 5, + 'type_tax_use': 'purchase', + }) + + cls.tax_0_purchase = cls.env['account.tax'].create({ + 'name': 'tax_0', + 'amount_type': 'percent', + 'amount': 0, + 'type_tax_use': 'purchase', + }) + + cls.tax_5 = cls.env['account.tax'].create({ + 'name': 'tax_5', + 'amount_type': 'percent', + 'amount': 5, + 'type_tax_use': 'sale', + }) + + cls.tax_5_incl = cls.env['account.tax'].create({ + 'name': 'tax_5_incl', + 'amount_type': 'percent', + 'amount': 5, + 'type_tax_use': 'sale', + 'price_include': True, + }) + @classmethod def setup_company_data(cls, company_name, chart_template): # OVERRIDE @@ -205,10 +234,99 @@ class TestCIIFR(TestUBLCommon): self.assertEqual(attachment.name, "factur-x.xml") self._assert_imported_invoice_from_etree(refund, attachment) + def test_export_tax_included(self): + """ + Tests whether the tax included price_units are correctly converted to tax excluded + amounts in the exported xml + """ + invoice = self._generate_move( + self.partner_1, + self.partner_2, + move_type='out_invoice', + invoice_line_ids=[ + { + 'product_id': self.product_a.id, + 'quantity': 1, + 'price_unit': 100, + 'tax_ids': [(6, 0, self.tax_5_incl.ids)], + }, + { + 'product_id': self.product_a.id, + 'quantity': 1, + 'price_unit': 100, + 'tax_ids': [(6, 0, self.tax_5.ids)], + }, + { + 'product_id': self.product_a.id, + 'quantity': 1, + 'price_unit': 200, + 'discount': 10, + 'tax_ids': [(6, 0, self.tax_5_incl.ids)], + }, + { + 'product_id': self.product_a.id, + 'quantity': 1, + 'price_unit': 200, + 'discount': 10, + 'tax_ids': [(6, 0, self.tax_5.ids)], + }, + ], + ) + self._assert_invoice_attachment( + invoice, + xpaths=''' + + ___ignore___ + + + ___ignore___ + + ''', + expected_file='from_odoo/facturx_out_invoice_tax_incl.xml' + ) + #################################################### # Test import #################################################### + def test_import_tax_included(self): + """ + Tests whether the tax included / tax excluded are correctly decoded when + importing a document. The imported xml represents the following invoice: + + Description Quantity Unit Price Disc (%) Taxes Amount + -------------------------------------------------------------------------------- + Product A 1 100 0 5% (incl) 95.24 + Product A 1 100 0 5% (not incl) 100 + Product A 2 200 10 5% (incl) 171.43 + Product A 2 200 10 5% (not incl) 180 + ----------------------- + Untaxed Amount: 546.67 + Taxes: 27.334 + ----------------------- + Total: 574.004 + """ + self._assert_imported_invoice_from_file( + subfolder='tests/test_files/from_odoo', + filename='facturx_out_invoice_tax_incl.xml', + amount_total=574.004, + amount_tax=27.334, + list_line_subtotals=[95.24, 100, 171.43, 180], + # /!\ The price_unit are different for taxes with price_include, because all amounts in Factur-X should be + # tax excluded. At import, the tax included amounts are thus converted into tax excluded ones. + # Yet, the line subtotals and total will be the same (if an equivalent tax exist with price_include = False) + list_line_price_unit=[95.24, 100, 190.48, 200], + # rounding error since for line 3: we round several times... + # when exporting the invoice, we compute the price tax excluded = 200/1.05 ~= 190.48 + # then, when computing the discount amount: 190.48 * 0.1 ~= 19.05 => price net amount = 171.43 + # Thus, at import: price_unit = 190.48, and discount = 100 * (1 - 171.43 / 190.48) = 10.001049979 + list_line_discount=[0, 0, 10, 10], + # Again, all taxes in the imported invoice are price_include = False + list_line_taxes=[self.tax_5_purchase]*4, + move_type='in_invoice', + currency_id=self.env['res.currency'].search([('name', '=', 'USD')], limit=1).id, + ) + def test_import_fnfe_examples(self): # Source: official documentation of the FNFE (subdirectory: "5. FACTUR-X 1.0.06 - Examples") subfolder = 'tests/test_files/from_factur-x_doc' diff --git a/addons/l10n_account_edi_ubl_cii_tests/tests/test_xml_cii_us.py b/addons/l10n_account_edi_ubl_cii_tests/tests/test_xml_cii_us.py index c90be3b22e4..4eb6b1db1ba 100644 --- a/addons/l10n_account_edi_ubl_cii_tests/tests/test_xml_cii_us.py +++ b/addons/l10n_account_edi_ubl_cii_tests/tests/test_xml_cii_us.py @@ -23,6 +23,12 @@ class TestCIIUS(TestUBLCommon): 'country_id': cls.env.ref('base.us').id, }) + cls.tax_0 = cls.env['account.tax'].create({ + 'name': "Tax 0%", + 'type_tax_use': 'purchase', + 'amount': 0, + }) + @classmethod def setup_company_data(cls, company_name, chart_template): # OVERRIDE