388 lines
15 KiB
Python
388 lines
15 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
import base64
|
|
|
|
from datetime import datetime
|
|
from freezegun import freeze_time
|
|
from lxml import etree
|
|
from pytz import timezone
|
|
from odoo import Command
|
|
|
|
from odoo.exceptions import ValidationError
|
|
from odoo.tests import tagged
|
|
from odoo.tools import misc
|
|
from odoo.addons.l10n_sa_edi.tests.common import TestSaEdiCommon
|
|
|
|
|
|
@tagged('post_install_l10n', '-at_install', 'post_install')
|
|
class TestEdiZatca(TestSaEdiCommon):
|
|
# """Test ZATCA EDI compliance for Saudi Arabia."""
|
|
|
|
def _test_document_generation(self, test_file_path, expected_xpath, freeze_time_at, additional_xpath='', document_type=False, move=False, move_data=False):
|
|
"""
|
|
Common helper to test document generation against expected XML.
|
|
"""
|
|
with freeze_time(freeze_time_at):
|
|
# Load expected XML
|
|
expected_xml = misc.file_open(test_file_path, 'rb').read()
|
|
expected_tree = self.get_xml_tree_from_string(expected_xml)
|
|
expected_tree = self.with_applied_xpath(expected_tree, expected_xpath)
|
|
|
|
creation_handlers = {
|
|
"invoice": self._create_invoice,
|
|
"credit_note": self._create_credit_note,
|
|
"debit_note": self._create_debit_note,
|
|
}
|
|
|
|
if additional_xpath:
|
|
expected_tree = self.with_applied_xpath(expected_tree, additional_xpath)
|
|
|
|
if move:
|
|
final_move = move
|
|
elif move_data and document_type in creation_handlers:
|
|
final_move = creation_handlers[document_type](**move_data)
|
|
else:
|
|
raise ValidationError("Either move or document_type + move_data need to be given")
|
|
|
|
# Generate ZATCA XML
|
|
if final_move.state != 'posted':
|
|
final_move.action_post()
|
|
|
|
final_move._l10n_sa_generate_unsigned_data()
|
|
generated_file = self.env['account.edi.format']._l10n_sa_generate_zatca_template(final_move)
|
|
current_tree = self.get_xml_tree_from_string(generated_file)
|
|
current_tree = self.with_applied_xpath(current_tree, self.remove_ubl_extensions_xpath)
|
|
|
|
# Assert
|
|
self.assertXmlTreeEqual(current_tree, expected_tree)
|
|
|
|
def testCreditNoteSimplified(self):
|
|
"""Test simplified credit note generation."""
|
|
move_data = {
|
|
'name': 'INV/2023/00034',
|
|
'invoice_date': '2023-03-10',
|
|
'invoice_date_due': '2023-03-10',
|
|
'partner_id': self.partner_sa_simplified,
|
|
'invoice_line_ids': [{
|
|
'product_id': self.product_burger.id,
|
|
'price_unit': self.product_burger.standard_price,
|
|
'quantity': 3,
|
|
'tax_ids': self.tax_15.ids,
|
|
}]
|
|
}
|
|
|
|
self._test_document_generation(
|
|
document_type='credit_note',
|
|
test_file_path='l10n_sa_edi/tests/compliance/simplified/credit.xml',
|
|
expected_xpath=self.credit_note_applied_xpath,
|
|
move_data=move_data,
|
|
freeze_time_at=datetime(2023, 3, 10, 14, 59, 38, tzinfo=timezone('Etc/GMT-3'))
|
|
)
|
|
|
|
def testCreditNoteStandard(self):
|
|
"""Test standard credit note generation."""
|
|
move_data = {
|
|
'name': 'INV/2022/00014',
|
|
'invoice_date': '2022-09-05',
|
|
'invoice_date_due': '2022-09-22',
|
|
'partner_id': self.partner_sa,
|
|
'invoice_line_ids': [{
|
|
'product_id': self.product_a.id,
|
|
'price_unit': self.product_a.standard_price,
|
|
'tax_ids': self.tax_15.ids,
|
|
}]
|
|
}
|
|
|
|
additional_xpath = '''
|
|
<xpath expr="(//*[local-name()='AdditionalDocumentReference']/*[local-name()='UUID'])[1]" position="replace">
|
|
<cbc:UUID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:UUID>
|
|
</xpath>
|
|
'''
|
|
|
|
self._test_document_generation(
|
|
document_type='credit_note',
|
|
test_file_path='l10n_sa_edi/tests/compliance/standard/credit.xml',
|
|
expected_xpath=self.credit_note_applied_xpath,
|
|
move_data=move_data,
|
|
freeze_time_at=datetime(2022, 9, 5, 9, 39, 15, tzinfo=timezone('Etc/GMT-3')),
|
|
additional_xpath=additional_xpath
|
|
)
|
|
|
|
def testDebitNoteSimplified(self):
|
|
"""Test simplified debit note generation."""
|
|
move_data = {
|
|
'name': 'INV/2023/00034',
|
|
'invoice_date': '2023-03-10',
|
|
'invoice_date_due': '2023-03-10',
|
|
'partner_id': self.partner_sa_simplified,
|
|
'invoice_line_ids': [{
|
|
'product_id': self.product_burger.id,
|
|
'price_unit': self.product_burger.standard_price,
|
|
'quantity': 2,
|
|
'tax_ids': self.tax_15.ids,
|
|
}]
|
|
}
|
|
|
|
self._test_document_generation(
|
|
document_type='debit_note',
|
|
test_file_path='l10n_sa_edi/tests/compliance/simplified/debit.xml',
|
|
expected_xpath=self.debit_note_applied_xpath,
|
|
move_data=move_data,
|
|
freeze_time_at=datetime(2023, 3, 10, 15, 1, 46, tzinfo=timezone('Etc/GMT-3'))
|
|
)
|
|
|
|
def testDebitNoteStandard(self):
|
|
"""Test standard debit note generation."""
|
|
move_data = {
|
|
'name': 'INV/2022/00001',
|
|
'invoice_date': '2022-09-05',
|
|
'invoice_date_due': '2022-09-22',
|
|
'partner_id': self.partner_sa,
|
|
'invoice_line_ids': [{
|
|
'product_id': self.product_b.id,
|
|
'price_unit': self.product_b.standard_price,
|
|
'tax_ids': self.tax_15.ids,
|
|
}]
|
|
}
|
|
|
|
additional_xpath = '''
|
|
<xpath expr="(//*[local-name()='AdditionalDocumentReference']/*[local-name()='UUID'])[1]" position="replace">
|
|
<cbc:UUID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:UUID>
|
|
</xpath>
|
|
'''
|
|
|
|
self._test_document_generation(
|
|
document_type='debit_note',
|
|
test_file_path='l10n_sa_edi/tests/compliance/standard/debit.xml',
|
|
expected_xpath=self.debit_note_applied_xpath,
|
|
move_data=move_data,
|
|
freeze_time_at=datetime(2022, 9, 5, 9, 45, 27, tzinfo=timezone('Etc/GMT-3')),
|
|
additional_xpath=additional_xpath
|
|
)
|
|
|
|
def testInvoiceSimplified(self):
|
|
"""Test simplified invoice generation."""
|
|
move_data = {
|
|
'name': 'INV/2023/00034',
|
|
'invoice_date': '2023-03-10',
|
|
'invoice_date_due': '2023-03-10',
|
|
'partner_id': self.partner_sa_simplified,
|
|
'invoice_line_ids': [{
|
|
'product_id': self.product_burger.id,
|
|
'price_unit': self.product_burger.standard_price,
|
|
'quantity': 3,
|
|
'tax_ids': self.tax_15.ids,
|
|
}]
|
|
}
|
|
|
|
self._test_document_generation(
|
|
document_type='invoice',
|
|
test_file_path='l10n_sa_edi/tests/compliance/simplified/invoice.xml',
|
|
expected_xpath=self.invoice_applied_xpath,
|
|
move_data=move_data,
|
|
freeze_time_at=datetime(2023, 3, 10, 14, 56, 55, tzinfo=timezone('Etc/GMT-3'))
|
|
)
|
|
|
|
def testInvoiceStandard(self):
|
|
"""Test standard invoice generation."""
|
|
move_data = {
|
|
'name': 'INV/2022/00014',
|
|
'invoice_date': '2022-09-05',
|
|
'invoice_date_due': '2022-09-22',
|
|
'partner_id': self.partner_sa,
|
|
'invoice_line_ids': [{
|
|
'product_id': self.product_a.id,
|
|
'price_unit': self.product_a.standard_price,
|
|
'tax_ids': self.tax_15.ids,
|
|
}]
|
|
}
|
|
|
|
self._test_document_generation(
|
|
document_type='invoice',
|
|
test_file_path='l10n_sa_edi/tests/compliance/standard/invoice.xml',
|
|
expected_xpath=self.invoice_applied_xpath,
|
|
move_data=move_data,
|
|
freeze_time_at=datetime(2022, 9, 5, 8, 20, 2, tzinfo=timezone('Etc/GMT-3'))
|
|
)
|
|
|
|
def testInvoiceWithZeroTax(self):
|
|
"""Test invoice generation with 0% tax on a line."""
|
|
tax_0 = self.env['account.tax'].create({
|
|
'name': 'Tax 0',
|
|
'amount_type': 'percent',
|
|
'amount': 0,
|
|
})
|
|
invoice = self._create_invoice(
|
|
name='INV/2022/00014',
|
|
invoice_date='2022-09-05',
|
|
invoice_date_due='2022-09-22',
|
|
partner_id=self.partner_sa,
|
|
invoice_line_ids=[{
|
|
'product_id': self.product_a.id,
|
|
'price_unit': 500,
|
|
'tax_ids': self.tax_15.ids,
|
|
}, {
|
|
'product_id': self.product_b.id,
|
|
'price_unit': -100,
|
|
'tax_ids': tax_0.ids,
|
|
}],
|
|
)
|
|
|
|
invoice.action_post()
|
|
xml_content = self.env['account.edi.format']._l10n_sa_generate_zatca_template(invoice)
|
|
xml_root = etree.fromstring(xml_content)
|
|
taxable_amount = xml_root.xpath(
|
|
"(//cac:TaxSubtotal)[2]/cbc:TaxableAmount",
|
|
namespaces=self.env['account.edi.xml.ubl_21.zatca']._l10n_sa_get_namespaces()
|
|
)[0].text.strip()
|
|
self.assertEqual(taxable_amount, '-100.00')
|
|
|
|
def testInvoiceWithDownpayment(self):
|
|
"""Test invoice generation with downpayment scenarios."""
|
|
if 'sale' not in self.env["ir.module.module"]._installed():
|
|
self.skipTest("Sale module is not installed")
|
|
|
|
freeze = datetime(2022, 9, 5, 8, 20, 2, tzinfo=timezone('Etc/GMT-3'))
|
|
|
|
# Helper to test generated files
|
|
saudi_pricelist = self.env['product.pricelist'].create({
|
|
'name': 'SAR',
|
|
'currency_id': self.env.ref('base.SAR').id
|
|
})
|
|
with freeze_time(freeze):
|
|
sale_order = self.env['sale.order'].create({
|
|
'partner_id': self.partner_sa.id,
|
|
'pricelist_id': saudi_pricelist.id,
|
|
'order_line': [
|
|
Command.create({
|
|
'product_id': self.product_a.id,
|
|
'price_unit': 1000,
|
|
'product_uom_qty': 1,
|
|
'tax_id': [Command.set(self.tax_15.ids)],
|
|
})
|
|
]
|
|
})
|
|
sale_order.action_confirm()
|
|
|
|
# Context for wizards
|
|
context = {
|
|
'active_model': 'sale.order',
|
|
'active_ids': [sale_order.id],
|
|
'active_id': sale_order.id,
|
|
'default_journal_id': self.customer_invoice_journal.id,
|
|
}
|
|
|
|
# Create downpayment invoice
|
|
downpayment_wizard = self.env['sale.advance.payment.inv'].with_context(context).create({
|
|
'advance_payment_method': 'fixed',
|
|
'fixed_amount': 115,
|
|
})
|
|
downpayment = downpayment_wizard._create_invoices(sale_order)
|
|
downpayment.invoice_date_due = '2022-09-22'
|
|
|
|
# Create final invoice
|
|
final_wizard = self.env['sale.advance.payment.inv'].with_context(context).create({})
|
|
final = final_wizard._create_invoices(sale_order)
|
|
final.invoice_line_ids.filtered('is_downpayment').name = 'Down Payment'
|
|
final.invoice_date_due = '2022-09-22'
|
|
|
|
# Test invoices
|
|
for move, test_file in [
|
|
(downpayment, "downpayment_invoice"),
|
|
(final, "final_invoice")
|
|
]:
|
|
with self.subTest(move=move, test_file=test_file):
|
|
self._test_document_generation(
|
|
test_file_path=f'l10n_sa_edi/tests/test_files/{test_file}.xml',
|
|
expected_xpath=self.invoice_applied_xpath,
|
|
freeze_time_at=freeze,
|
|
move=move,
|
|
)
|
|
|
|
# Test credit notes
|
|
for move, test_file in [
|
|
(downpayment, "downpayment_credit_note"),
|
|
(final, "final_credit_note")
|
|
]:
|
|
with self.subTest(move=move, test_file=test_file):
|
|
# Create refund
|
|
wiz_context = {
|
|
'active_model': 'account.move',
|
|
'active_ids': [move.id],
|
|
'default_journal_id': move.journal_id.id,
|
|
}
|
|
refund_wizard = self.env['account.move.reversal'].with_context(wiz_context).create({
|
|
'reason': 'please reverse :c',
|
|
'date': '2022-09-05',
|
|
})
|
|
refund_invoice = self.env['account.move'].browse(refund_wizard.reverse_moves()['res_id'])
|
|
refund_invoice.invoice_date_due = '2022-09-22'
|
|
self._test_document_generation(
|
|
test_file_path=f'l10n_sa_edi/tests/test_files/{test_file}.xml',
|
|
expected_xpath=self.credit_note_applied_xpath,
|
|
freeze_time_at=freeze,
|
|
move=refund_invoice,
|
|
)
|
|
|
|
def testInvoiceWithRetention(self):
|
|
"""Test standard invoice generation."""
|
|
|
|
retention_tax = self.env['account.tax'].create({
|
|
'l10n_sa_is_retention': True,
|
|
'name': 'Retention Tax',
|
|
'amount_type': 'percent',
|
|
'amount': -10.0,
|
|
})
|
|
|
|
move_data = {
|
|
'name': 'INV/2022/00014',
|
|
'invoice_date': '2022-09-05',
|
|
'invoice_date_due': '2022-09-22',
|
|
'partner_id': self.partner_sa,
|
|
'invoice_line_ids': [{
|
|
'product_id': self.product_a.id,
|
|
'price_unit': self.product_a.standard_price,
|
|
'tax_ids': self.tax_15.ids + retention_tax.ids,
|
|
}]
|
|
}
|
|
|
|
self._test_document_generation(
|
|
document_type='invoice',
|
|
test_file_path='l10n_sa_edi/tests/compliance/standard/invoice.xml',
|
|
expected_xpath=self.invoice_applied_xpath,
|
|
move_data=move_data,
|
|
freeze_time_at=datetime(2022, 9, 5, 8, 20, 2, tzinfo=timezone('Etc/GMT-3'))
|
|
)
|
|
|
|
def testCompanyOnSimplifiedInvoiceQR(self):
|
|
move_data = {
|
|
'name': 'INV/2025/00012',
|
|
'invoice_date': '2025-07-05',
|
|
'invoice_date_due': '2025-07-12',
|
|
'company_id': self.sa_branch,
|
|
'partner_id': self.partner_sa_simplified,
|
|
'invoice_line_ids': [{
|
|
'product_id': self.product_a.id,
|
|
'price_unit': self.product_a.standard_price,
|
|
'tax_ids': self.tax_15.ids,
|
|
}],
|
|
}
|
|
|
|
# Fetch company name from xml
|
|
invoice = self._create_invoice(**move_data)
|
|
invoice.action_post()
|
|
xml_content = self.env['account.edi.format']._l10n_sa_generate_zatca_template(invoice)
|
|
xml_root = etree.fromstring(xml_content)
|
|
xml_company_name = xml_root.xpath(
|
|
"//cac:AccountingSupplierParty/cac:Party/cac:PartyName/cbc:Name",
|
|
namespaces=self.env['account.edi.xml.ubl_21.zatca']._l10n_sa_get_namespaces()
|
|
)[0].text.strip()
|
|
|
|
# Fetch company name from QR code
|
|
# Format: Tag (1 Byte) - Length (1 Byte) - Value
|
|
invoice._l10n_sa_generate_unsigned_data()
|
|
decoded_qr = base64.b64decode(invoice.l10n_sa_qr_code_str)
|
|
length = decoded_qr[1]
|
|
qr_company_name = decoded_qr[2:2 + length].decode()
|
|
|
|
self.assertEqual(xml_company_name, qr_company_name, "Seller name on the xml does not match the seller name on the QR code")
|