189 lines
10 KiB
Python
189 lines
10 KiB
Python
|
from odoo import Command
|
||
|
from odoo.tests import tagged
|
||
|
from odoo.tools.float_utils import float_is_zero
|
||
|
from odoo.addons.l10n_jo_edi.tests.jo_edi_common import JoEdiCommon
|
||
|
from odoo.addons.l10n_jo_edi.models.account_edi_xml_ubl_21_jo import JO_MAX_DP
|
||
|
|
||
|
|
||
|
@tagged('post_install_l10n', 'post_install', '-at_install')
|
||
|
class TestJoEdiPrecision(JoEdiCommon):
|
||
|
def _equality_check(self, vals_dict, up_to_jo_max_dp=True):
|
||
|
def equal_strict(val1, val2):
|
||
|
return val1 == val2
|
||
|
|
||
|
def equal_jo_max_dp(val1, val2):
|
||
|
return float_is_zero(val1 - val2, JO_MAX_DP)
|
||
|
|
||
|
equals = equal_jo_max_dp if up_to_jo_max_dp else equal_strict
|
||
|
first_tuple = None
|
||
|
error_message = ""
|
||
|
for label, value in vals_dict.items():
|
||
|
if not first_tuple:
|
||
|
first_tuple = (label, value)
|
||
|
else:
|
||
|
if not equals(value, first_tuple[1]):
|
||
|
error_message += f"{label} ({value}) != {first_tuple[0]} ({first_tuple[1]})\n"
|
||
|
return error_message
|
||
|
|
||
|
def _extract_vals_from_subtotals(self, subtotals, defaults):
|
||
|
for subtotal in subtotals:
|
||
|
tax_percent = float(subtotal.findtext('{*}TaxCategory/{*}Percent', default=-1))
|
||
|
if tax_percent == -1: # special (fixed amount) tax
|
||
|
defaults.update({
|
||
|
'taxable_amount_special': float(subtotal.findtext('{*}TaxableAmount')),
|
||
|
'tax_amount_special': float(subtotal.findtext('{*}TaxAmount')),
|
||
|
})
|
||
|
defaults['total_tax_amount'] += defaults['tax_amount_special']
|
||
|
else:
|
||
|
defaults.update({
|
||
|
'taxable_amount_general': float(subtotal.findtext('{*}TaxableAmount')),
|
||
|
'tax_amount_general_subtotal': float(subtotal.findtext('{*}TaxAmount')),
|
||
|
'tax_percent': tax_percent / 100,
|
||
|
})
|
||
|
defaults['total_tax_amount'] += defaults['tax_amount_general_subtotal']
|
||
|
|
||
|
return defaults
|
||
|
|
||
|
def _validate_jo_edi_numbers(self, xml_string):
|
||
|
"""
|
||
|
TLDR: This method checks that units sum up to total values.
|
||
|
===================================================================================================
|
||
|
Problem statement:
|
||
|
When an EDI is submitted to JoFotara portal, multiple validations are executed to ensure invoice data integrity.
|
||
|
The most important ones of these validations are the following:-
|
||
|
--------------------------- ▼ invoice line level ▼ ---------------------------
|
||
|
1. line_extension_amount = (price_unit * quantity) - discount
|
||
|
2. taxable_amount = line_extension_amount
|
||
|
3. rounding_amount = line_extension_amount + general_tax_amount + special_tax_amount
|
||
|
--------------------------- ▼ invoice level ▼ ---------------------------------
|
||
|
4. tax_exclusive_amount = sum(price_unit * quantity)
|
||
|
5. tax_inclusive_amount = sum(price_unit * quantity - discount + general_tax_amount + special_tax_amount)
|
||
|
6. payable_amount = tax_inclusive_amount
|
||
|
-------------------------------------------------------------------------------
|
||
|
The JoFotara portal, however, has no tolerance with rounding errors up to 9 decimal places.
|
||
|
Hence, the reported values are expected to be up to 9 decimal places,
|
||
|
and the aggregated units should match reported totals up to 9 decimal places.
|
||
|
Moreover, reported totals have to equal (or at least be as close as possible) to totals stored in Odoo.
|
||
|
And since the JOD has precision of 3 decimal places, everything is stored in Odoo approximated to 3 decimal places.
|
||
|
-------------------------------------------------------------------------------
|
||
|
This method runs validations in a fashion similar to those running on the JoFotara portal.
|
||
|
It returns all the errors encountered as a string.
|
||
|
"""
|
||
|
root = self.get_xml_tree_from_string(xml_string)
|
||
|
error_message = ""
|
||
|
|
||
|
total_discount = float(root.findtext('./{*}AllowanceCharge/{*}Amount'))
|
||
|
total_tax = float(root.findtext('./{*}TaxTotal/{*}TaxAmount', default=0))
|
||
|
|
||
|
tax_exclusive_amount = float(root.findtext('./{*}LegalMonetaryTotal/{*}TaxExclusiveAmount'))
|
||
|
tax_inclusive_amount = float(root.findtext('./{*}LegalMonetaryTotal/{*}TaxInclusiveAmount'))
|
||
|
monetary_values_discount = float(root.findtext('./{*}LegalMonetaryTotal/{*}AllowanceTotalAmount'))
|
||
|
payable_amount = float(root.findtext('./{*}LegalMonetaryTotal/{*}PayableAmount'))
|
||
|
|
||
|
error_message += self._equality_check({ # They have to be exactly the same, no decimal difference is tolerated
|
||
|
'Monetary Values discount': monetary_values_discount,
|
||
|
'Total Discount': total_discount,
|
||
|
}, up_to_jo_max_dp=False)
|
||
|
error_message += self._equality_check({ # They have to be exactly the same, no decimal difference is tolerated
|
||
|
'Payable Amount': payable_amount,
|
||
|
'Tax Inclusive Amount': tax_inclusive_amount,
|
||
|
}, up_to_jo_max_dp=False)
|
||
|
|
||
|
lines = []
|
||
|
for xml_line in root.findall('./{*}InvoiceLine'):
|
||
|
line_extension_amount = float(xml_line.findtext('{*}LineExtensionAmount'))
|
||
|
line = {
|
||
|
'id': xml_line.findtext('{*}ID'),
|
||
|
'quantity': float(xml_line.findtext('{*}InvoicedQuantity')),
|
||
|
'line_extension_amount': line_extension_amount,
|
||
|
'tax_amount_general': float(xml_line.findtext('{*}TaxTotal/{*}TaxAmount', default=0)),
|
||
|
'rounding_amount': float(xml_line.findtext('{*}TaxTotal/{*}RoundingAmount', default=line_extension_amount)), # defaults to line_extension_amount in the absence of taxes
|
||
|
**self._extract_vals_from_subtotals(
|
||
|
subtotals=xml_line.findall('{*}TaxTotal/{*}TaxSubtotal'),
|
||
|
defaults={
|
||
|
'taxable_amount_general': line_extension_amount,
|
||
|
'tax_amount_general_subtotal': 0,
|
||
|
'tax_percent': 0,
|
||
|
'taxable_amount_special': line_extension_amount,
|
||
|
'tax_amount_special': 0,
|
||
|
'total_tax_amount': 0,
|
||
|
}),
|
||
|
'price_unit': float(xml_line.findtext('{*}Price/{*}PriceAmount')),
|
||
|
'discount': float(xml_line.findtext('{*}Price/{*}AllowanceCharge/{*}Amount')),
|
||
|
}
|
||
|
lines.append(line)
|
||
|
line_errors = self._equality_check({
|
||
|
# taxable_amount = line_extension_amount = price_unit * quantity - discount
|
||
|
'General Taxable Amount': line['taxable_amount_general'],
|
||
|
'Special Taxable Amount': line['taxable_amount_special'],
|
||
|
'Line Extension Amount': line['line_extension_amount'],
|
||
|
'Price Unit * Quantity - Discount': line['price_unit'] * line['quantity'] - line['discount'],
|
||
|
}) + self._equality_check({
|
||
|
# rounding_amount = line_extension_amount + total_tax_amount
|
||
|
'Rounding Amount': line['rounding_amount'],
|
||
|
'Line Extension Amount + Total Tax': line['line_extension_amount'] + line['total_tax_amount'],
|
||
|
}) + self._equality_check({
|
||
|
'General Tax Amount': line['tax_amount_general'],
|
||
|
'General Tax Amount in subtotal': line['tax_amount_general_subtotal'],
|
||
|
'Taxable Amount * Tax Percent': (line['taxable_amount_general'] + line['tax_amount_special']) * line['tax_percent'],
|
||
|
})
|
||
|
if line_errors:
|
||
|
error_message += f"Errors on the line {line['id']}\n"
|
||
|
error_message += line_errors
|
||
|
error_message += "-------------------------------------------------------------------------\n"
|
||
|
|
||
|
aggregated_tax_exclusive_amount = sum(line['price_unit'] * line['quantity'] for line in lines)
|
||
|
aggregated_tax_inclusive_amount = sum(line['price_unit'] * line['quantity'] - line['discount'] + line['total_tax_amount'] for line in lines)
|
||
|
aggregated_tax_amount = sum(line['tax_amount_general'] for line in lines)
|
||
|
aggregated_discount_amount = sum(line['discount'] for line in lines)
|
||
|
|
||
|
error_message += self._equality_check({
|
||
|
'Tax Exclusive Amount': tax_exclusive_amount,
|
||
|
'Aggregated Tax Exclusive Amount': aggregated_tax_exclusive_amount,
|
||
|
}) + self._equality_check({
|
||
|
'Tax Inclusive Amount': tax_inclusive_amount,
|
||
|
'Aggregated Tax Inclusive Amount': aggregated_tax_inclusive_amount,
|
||
|
'Tax Exclusive Amount - Total Discount + Total Tax': tax_exclusive_amount - total_discount + sum(line['total_tax_amount'] for line in lines),
|
||
|
}) + self._equality_check({
|
||
|
'Tax Amount': total_tax,
|
||
|
'Aggregated Tax Amount': aggregated_tax_amount,
|
||
|
}) + self._equality_check({
|
||
|
'Discount Amount': total_discount,
|
||
|
'Aggregated Discount Amount': aggregated_discount_amount,
|
||
|
})
|
||
|
|
||
|
return error_message
|
||
|
|
||
|
def test_jo_sales_invoice_precision(self):
|
||
|
eur = self.env.ref('base.EUR')
|
||
|
self.setup_currency_rate(eur, 1.41)
|
||
|
self.company.l10n_jo_edi_taxpayer_type = 'sales'
|
||
|
self.company.l10n_jo_edi_sequence_income_source = '16683693'
|
||
|
|
||
|
invoice_vals = {
|
||
|
'name': 'TestEIN022',
|
||
|
'currency_id': eur.id,
|
||
|
'date': '2023-11-12',
|
||
|
'invoice_line_ids': [
|
||
|
Command.create({
|
||
|
'product_id': self.product_a.id,
|
||
|
'quantity': 3.48,
|
||
|
'price_unit': 1.56,
|
||
|
'discount': 2.5,
|
||
|
'tax_ids': [Command.set(self.jo_general_tax_16_included.ids)],
|
||
|
}),
|
||
|
Command.create({
|
||
|
'product_id': self.product_b.id,
|
||
|
'quantity': 6.02,
|
||
|
'price_unit': 2.79,
|
||
|
'discount': 2.5,
|
||
|
'tax_ids': [Command.set(self.jo_general_tax_16_included.ids)],
|
||
|
}),
|
||
|
],
|
||
|
}
|
||
|
invoice = self._l10n_jo_create_invoice(invoice_vals)
|
||
|
|
||
|
generated_file = self.env['account.edi.xml.ubl_21.jo']._export_invoice(invoice)[0]
|
||
|
errors = self._validate_jo_edi_numbers(generated_file)
|
||
|
self.assertFalse(errors, errors)
|