diff --git a/addons/l10n_sa_edi/__init__.py b/addons/l10n_sa_edi/__init__.py
new file mode 100644
index 00000000000..f0111a2faf4
--- /dev/null
+++ b/addons/l10n_sa_edi/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+from . import models, wizard
diff --git a/addons/l10n_sa_edi/__manifest__.py b/addons/l10n_sa_edi/__manifest__.py
new file mode 100644
index 00000000000..3818fdc7288
--- /dev/null
+++ b/addons/l10n_sa_edi/__manifest__.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+ 'name': 'Saudi Arabia - E-invoicing',
+ 'countries': ['sa'],
+ 'version': '0.1',
+ 'depends': [
+ 'account_edi_ubl_cii',
+ 'account_debit_note',
+ 'l10n_sa',
+ 'base_vat'
+ ],
+ 'author': 'Odoo',
+ 'summary': """
+ E-Invoicing, Universal Business Language
+ """,
+ 'description': """
+ E-invoice implementation for the Kingdom of Saudi Arabia
+ """,
+ 'category': 'Accounting/Localizations/EDI',
+ 'license': 'LGPL-3',
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'data/account_edi_format.xml',
+ 'data/ubl_21_zatca.xml',
+ 'data/res_country_data.xml',
+ 'wizard/l10n_sa_edi_otp_wizard.xml',
+ 'views/account_tax_views.xml',
+ 'views/account_journal_views.xml',
+ 'views/res_partner_views.xml',
+ 'views/res_company_views.xml',
+ 'views/res_config_settings_view.xml',
+ 'views/report_invoice.xml',
+ ],
+ 'demo': [
+ 'demo/demo_company.xml',
+ ],
+ 'assets': {
+ 'web.assets_backend': [
+ 'l10n_sa_edi/static/src/scss/form_view.scss',
+ ]
+ }
+}
diff --git a/addons/l10n_sa_edi/data/account_edi_format.xml b/addons/l10n_sa_edi/data/account_edi_format.xml
new file mode 100644
index 00000000000..d340e6ccc0d
--- /dev/null
+++ b/addons/l10n_sa_edi/data/account_edi_format.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+ ZATCA (Saudi Arabia)
+ sa_zatca
+
+
+
+
diff --git a/addons/l10n_sa_edi/data/pre-hash_invoice.xsl b/addons/l10n_sa_edi/data/pre-hash_invoice.xsl
new file mode 100644
index 00000000000..6c69d566615
--- /dev/null
+++ b/addons/l10n_sa_edi/data/pre-hash_invoice.xsl
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/l10n_sa_edi/data/res_country_data.xml b/addons/l10n_sa_edi/data/res_country_data.xml
new file mode 100644
index 00000000000..3e0b47a6dbf
--- /dev/null
+++ b/addons/l10n_sa_edi/data/res_country_data.xml
@@ -0,0 +1,38 @@
+
+
+
+ sa.partner.form.address
+ res.partner
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/l10n_sa_edi/data/ubl_21_zatca.xml b/addons/l10n_sa_edi/data/ubl_21_zatca.xml
new file mode 100644
index 00000000000..2ab7ef4076b
--- /dev/null
+++ b/addons/l10n_sa_edi/data/ubl_21_zatca.xml
@@ -0,0 +1,285 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ urn:oasis:names:specification:ubl:dsig:enveloped:xades
+
+
+
+ urn:oasis:names:specification:ubl:signature:1
+ urn:oasis:names:specification:ubl:signature:Invoice
+
+
+
+
+
+
+
+
+ not(//ancestor-or-self::ext:UBLExtensions)
+
+
+ not(//ancestor-or-self::cac:Signature)
+
+
+
+ not(//ancestor-or-self::cac:AdditionalDocumentReference[cbc:ID='QR'])
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ QR
+
+ N/A
+
+
+
+
+ PIH
+
+
+
+
+
+
+ ICV
+
+
+
+
+ urn:oasis:names:specification:ubl:signature:Invoice
+ urn:oasis:names:specification:ubl:dsig:enveloped:xades
+
+
+
+
+
+
+
diff --git a/addons/l10n_sa_edi/demo/demo_company.xml b/addons/l10n_sa_edi/demo/demo_company.xml
new file mode 100644
index 00000000000..cf8cb4c14c0
--- /dev/null
+++ b/addons/l10n_sa_edi/demo/demo_company.xml
@@ -0,0 +1,52 @@
+
+
+
+
+ 310175397400003
+
+ Somewhere close to Mecca
+ 1234
+ 1234
+ OTH
+ 3101753974
+
+
+
+ Mohammed Maamour
+ Al Amir Mohammed Bin Abdul Aziz Street
+ المدينة المنورة
+
+
+ 42318
+ +966 55 777 8888
+ info@company.saexample.com
+ www.saexample.com
+ 123456789
+
+
+
+ ARAMCO Medinah Branch
+ Al Amir Mohammed Bin Abdul Aziz Street
+ Ammi Saysi
+ المدينة المنورة
+
+
+ 42317
+ 311112111111113
+ company
+ +966 55 999 1010
+ info@company.saexample.com
+ www.saexample.com
+ 1234
+ 1234
+ 123456789
+
+
+
+
+
+
+
diff --git a/addons/l10n_sa_edi/models/__init__.py b/addons/l10n_sa_edi/models/__init__.py
new file mode 100644
index 00000000000..47185ed0ba3
--- /dev/null
+++ b/addons/l10n_sa_edi/models/__init__.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+from . import account_edi_format
+from . import account_journal
+from . import account_move
+from . import account_tax
+from . import res_partner
+from . import res_company
+from . import res_config_settings
+from . import account_edi_xml_ubl_21_zatca
diff --git a/addons/l10n_sa_edi/models/account_edi_document.py b/addons/l10n_sa_edi/models/account_edi_document.py
new file mode 100644
index 00000000000..9858f394861
--- /dev/null
+++ b/addons/l10n_sa_edi/models/account_edi_document.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+
+from odoo import models
+
+
+class AccountEdiDocument(models.Model):
+ _inherit = 'account.edi.document'
+
+ def _prepare_jobs(self):
+ """
+ Override to achieve the following:
+
+ If there is a job to process that may already be part of the chain (posted invoice that timed out),
+ Moves it at the beginning of the list.
+ """
+ jobs = super()._prepare_jobs()
+ if len(jobs) > 1:
+ move_first_index = 0
+ for index, job in enumerate(jobs):
+ documents = job['documents']
+ if any(d.edi_format_id.code == 'sa_zatca' and d.state == 'to_send' and d.move_id.l10n_sa_chain_index for d in documents):
+ move_first_index = index
+ break
+ jobs = [jobs[move_first_index]] + jobs[:move_first_index] + jobs[move_first_index + 1:]
+
+ return jobs
diff --git a/addons/l10n_sa_edi/models/account_edi_format.py b/addons/l10n_sa_edi/models/account_edi_format.py
new file mode 100644
index 00000000000..4b666e016a3
--- /dev/null
+++ b/addons/l10n_sa_edi/models/account_edi_format.py
@@ -0,0 +1,471 @@
+import json
+from hashlib import sha256
+from base64 import b64decode, b64encode
+from lxml import etree
+from datetime import date, datetime
+from odoo import models, fields, _, api
+from odoo.exceptions import UserError
+from cryptography.hazmat.primitives.serialization import load_pem_private_key
+from cryptography.hazmat.primitives.asymmetric.ec import ECDSA
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.backends import default_backend
+from cryptography.x509 import load_der_x509_certificate
+
+
+class AccountEdiFormat(models.Model):
+ _inherit = 'account.edi.format'
+
+ """
+ Once the journal has been successfully onboarded, we can clear/report invoices through the ZATCA API:
+ A) STANDARD Invoice:
+ Make a call to the Clearance API '/invoices/clearance/single'.
+ This will validate the invoice, sign it and apply a QR code then return the result.
+ B) SIMPLIFIED Invoice:
+ Make a call to the Reporting API '/invoices/reporting/single'.
+ This will validate the invoice then return the result.
+ The X509 Certificate and password from the PCSID API need to be provided in the request headers.
+ """
+
+ # ====== Helper Functions =======
+
+ def _l10n_sa_get_zatca_datetime(self, timestamp):
+ return fields.Datetime.context_timestamp(self.with_context(tz='Asia/Riyadh'), timestamp)
+
+ def _l10n_sa_xml_node_content(self, root, xpath, namespaces=None):
+ namespaces = namespaces or self.env['account.edi.xml.ubl_21.zatca']._l10n_sa_get_namespaces()
+ return etree.tostring(root.xpath(xpath, namespaces=namespaces)[0], with_tail=False,
+ encoding='utf-8', method='xml')
+
+ # ====== Xades Signing =======
+
+ @api.model
+ def _l10n_sa_get_digital_signature(self, company_id, invoice_hash):
+ """
+ Generate an ECDSA SHA256 digital signature for the XML eInvoice
+ """
+ decoded_hash = b64decode(invoice_hash).decode()
+ private_key = load_pem_private_key(company_id.sudo().l10n_sa_private_key, password=None, backend=default_backend())
+ signature = private_key.sign(decoded_hash.encode(), ECDSA(hashes.SHA256()))
+ return b64encode(signature)
+
+ def _l10n_sa_calculate_signed_properties_hash(self, issuer_name, serial_number, signing_time, public_key):
+ """
+ Calculate the SHA256 value of the SignedProperties XML node. The algorithm used by ZATCA expects the indentation
+ of the nodes to start with 40 spaces, except for the root SignedProperties node.
+ """
+ signed_properties = etree.fromstring(self.env['ir.qweb']._render('l10n_sa_edi.export_sa_zatca_ubl_signed_properties', {
+ 'issuer_name': issuer_name,
+ 'serial_number': serial_number,
+ 'signing_time': signing_time,
+ 'public_key_hashing': public_key,
+ }))
+ etree.indent(signed_properties, space=' ')
+ signed_properties_split = etree.tostring(signed_properties).decode().split('\n')
+ signed_properties_final = ""
+ for index, line in enumerate(signed_properties_split):
+ if index == 0:
+ signed_properties_final += line
+ else:
+ signed_properties_final += (' ' * 36) + line
+ if index != len(signed_properties_final) - 1:
+ signed_properties_final += '\n'
+ signed_properties_final = etree.tostring(etree.fromstring(signed_properties_final))
+ return b64encode(sha256(signed_properties_final).hexdigest().encode()).decode()
+
+ def _l10n_sa_sign_xml(self, xml_content, certificate_str, signature):
+ """
+ Function that signs XML content of a UBL document with a provided B64 encoded X509 certificate
+ """
+ root = etree.fromstring(xml_content)
+ etree.indent(root, space=' ')
+
+ def _set_content(xpath, content):
+ node = root.xpath(xpath)[0]
+ node.text = content
+
+ b64_decoded_cert = b64decode(certificate_str)
+ x509_certificate = load_der_x509_certificate(b64decode(b64_decoded_cert.decode()), default_backend())
+
+ issuer_name = ', '.join([s.rfc4514_string() for s in x509_certificate.issuer.rdns[::-1]])
+ serial_number = str(x509_certificate.serial_number)
+ signing_time = self._l10n_sa_get_zatca_datetime(datetime.now()).strftime('%Y-%m-%dT%H:%M:%SZ')
+ public_key_hashing = b64encode(sha256(b64_decoded_cert).hexdigest().encode()).decode()
+
+ signed_properties_hash = self._l10n_sa_calculate_signed_properties_hash(issuer_name, serial_number,
+ signing_time, public_key_hashing)
+
+ _set_content("//*[local-name()='X509IssuerName']", issuer_name)
+ _set_content("//*[local-name()='X509SerialNumber']", serial_number)
+ _set_content("//*[local-name()='SignedSignatureProperties']/*[local-name()='SigningTime']", signing_time)
+ _set_content("//*[local-name()='SignedSignatureProperties']//*[local-name()='DigestValue']", public_key_hashing)
+
+ prehash_content = etree.tostring(root)
+ invoice_hash = self.env['account.edi.xml.ubl_21.zatca']._l10n_sa_generate_invoice_xml_hash(prehash_content,
+ 'digest')
+
+ _set_content("//*[local-name()='SignatureValue']", signature)
+ _set_content("//*[local-name()='X509Certificate']", b64_decoded_cert.decode())
+ _set_content("//*[local-name()='SignatureInformation']//*[local-name()='DigestValue']", invoice_hash)
+ _set_content("//*[@URI='#xadesSignedProperties']/*[local-name()='DigestValue']", signed_properties_hash)
+
+ return etree.tostring(root, with_tail=False)
+
+ def _l10n_sa_assert_clearance_status(self, invoice, clearance_data):
+ """
+ Assert Clearance status. To be overridden in case there are any other cases to be accounted for
+ """
+ mode = 'reporting' if invoice._l10n_sa_is_simplified() else 'clearance'
+ if mode == 'clearance' and clearance_data.get('clearanceStatus', '') != 'CLEARED':
+ return {'error': _("Invoice could not be cleared: \r\n %s ") % clearance_data, 'blocking_level': 'error'}
+ elif mode == 'reporting' and clearance_data.get('reportingStatus', '') != 'REPORTED':
+ return {'error': _("Invoice could not be reported: \r\n %s ") % clearance_data, 'blocking_level': 'error'}
+ return clearance_data
+
+ # ====== UBL Document Rendering & Submission =======
+
+ def _l10n_sa_postprocess_zatca_template(self, xml_content):
+ """
+ Post-process xml content generated according to the ZATCA UBL specifications. Specifically, this entails:
+ - Force the xmlns:ext namespace on the root element (Invoice). This is required, since, by default
+ the generated UBL file does not have any ext namespaced element, so the namespace is removed
+ since it is unused.
+ """
+
+ # Append UBLExtensions to the XML content
+ ubl_extensions = etree.fromstring(self.env['ir.qweb']._render('l10n_sa_edi.export_sa_zatca_ubl_extensions'))
+ root = etree.fromstring(xml_content)
+ root.insert(0, ubl_extensions)
+
+ # Force xmlns:ext namespace on UBl file
+ ns_map = {'ext': 'urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2'}
+ etree.cleanup_namespaces(root, top_nsmap=ns_map, keep_ns_prefixes=['ext'])
+
+ return etree.tostring(root, with_tail=False).decode()
+
+ def _l10n_sa_generate_zatca_template(self, invoice):
+ """
+ Render the ZATCA UBL file
+ """
+ xml_content, errors = self.env['account.edi.xml.ubl_21.zatca']._export_invoice(invoice)
+ if errors:
+ return {
+ 'error': _("Could not generate Invoice UBL content: %s") % ", \n".join(errors),
+ 'blocking_level': 'error'
+ }
+ return self._l10n_sa_postprocess_zatca_template(xml_content)
+
+ def _l10n_sa_submit_einvoice(self, invoice, signed_xml, PCSID_data):
+ """
+ Submit a generated Invoice UBL file by making calls to the following APIs:
+ - A. Clearance API: Submit a standard Invoice to ZATCA for validation, returns signed UBL
+ - B. Reporting API: Submit a simplified Invoice to ZATCA for validation
+ """
+ clearance_data = invoice.journal_id._l10n_sa_api_clearance(invoice, signed_xml.decode(), PCSID_data)
+ if clearance_data.get('json_errors'):
+ errors = [json.loads(j).get('validationResults', {}) for j in clearance_data['json_errors']]
+ error_msg = ''
+ is_warning = True
+ for error in errors:
+ validation_results = error.get('validationResults', {})
+ for err in validation_results.get('warningMessages', []):
+ error_msg += '\n - %s | %s' % (err['code'], err['message'])
+ for err in validation_results.get('errorMessages', []):
+ is_warning = False
+ error_msg += '\n - %s | %s' % (err['code'], err['message'])
+ return {
+ 'error': error_msg,
+ 'rejected': not is_warning,
+ 'response': signed_xml.decode(),
+ 'blocking_level': 'warning' if is_warning else 'error'
+ }
+ if not clearance_data.get('error'):
+ return self._l10n_sa_assert_clearance_status(invoice, clearance_data)
+ return clearance_data
+
+ def _l10n_sa_postprocess_einvoice_submission(self, invoice, signed_xml, clearance_data):
+ """
+ Once an invoice has been successfully submitted, it is returned as a Cleared invoice, on which data
+ from ZATCA was applied. To be overridden to account for other cases, such as Reporting.
+ """
+ if invoice._l10n_sa_is_simplified():
+ # if invoice is B2C, it is a SIMPLIFIED invoice, and thus it is only reported and returns
+ # no signed invoice. In this case, we just return the original content
+ return signed_xml.decode()
+ return b64decode(clearance_data['clearedInvoice']).decode()
+
+ def _l10n_sa_apply_qr_code(self, invoice, xml_content):
+ """
+ Apply QR code on Invoice UBL content
+ """
+ root = etree.fromstring(xml_content)
+ qr_code = invoice.l10n_sa_qr_code_str
+ qr_node = root.xpath('//*[local-name()="ID"][text()="QR"]/following-sibling::*/*')[0]
+ qr_node.text = qr_code
+ return etree.tostring(root, with_tail=False)
+
+ def _l10n_sa_get_signed_xml(self, invoice, unsigned_xml, x509_cert):
+ """
+ Helper method to sign the provided XML, apply the QR code in the case if Simplified invoices (B2C), then
+ return the signed XML
+ """
+ signed_xml = self._l10n_sa_sign_xml(unsigned_xml, x509_cert, invoice.l10n_sa_invoice_signature)
+ if invoice._l10n_sa_is_simplified():
+ return self._l10n_sa_apply_qr_code(invoice, signed_xml)
+ return signed_xml
+
+ def _l10n_sa_export_zatca_invoice(self, invoice, xml_content=None):
+ """
+ Generate a ZATCA compliant UBL file, make API calls to authenticate, sign and include QR Code and
+ Cryptographic Stamp, then create an attachment with the final contents of the UBL file
+ """
+ self.ensure_one()
+
+ # Prepare UBL invoice values and render XML file
+ unsigned_xml = xml_content or self._l10n_sa_generate_zatca_template(invoice)
+
+ # Load PCISD data and X509 certificate
+ try:
+ PCSID_data = invoice.journal_id._l10n_sa_api_get_pcsid()
+ except UserError as e:
+ return {'error': _("Could not generate PCSID values: \n") + e.args[0], 'blocking_level': 'error'}
+ x509_cert = PCSID_data['binarySecurityToken']
+
+ # Apply Signature/QR code on the generated XML document
+ try:
+ signed_xml = self._l10n_sa_get_signed_xml(invoice, unsigned_xml, x509_cert)
+ except UserError as e:
+ return {
+ 'error': _("Could not generate signed XML values: \n") + e.args[0],
+ 'blocking_level': 'error',
+ 'response': unsigned_xml
+ }
+
+ # Once the XML content has been generated and signed, we submit it to ZATCA
+ return self._l10n_sa_submit_einvoice(invoice, signed_xml, PCSID_data), signed_xml
+
+ def _l10n_sa_check_partner_missing_info(self, partner_id, fields_to_check):
+ """
+ Helper function to check if ZATCA mandated partner fields are missing for a specified partner record
+ """
+ missing = []
+ for field in fields_to_check:
+ field_value = partner_id[field[0]]
+ if not field_value or (len(field) == 3 and not field[2](partner_id, field_value)):
+ missing.append(field[1])
+ return missing
+
+ def _l10n_sa_check_seller_missing_info(self, invoice):
+ """
+ Helper function to check if ZATCA mandated partner fields are missing for the seller
+ """
+ partner_id = invoice.company_id.partner_id.commercial_partner_id
+ fields_to_check = [
+ ('l10n_sa_edi_building_number', _('Building Number for the Buyer is required on Standard Invoices')),
+ ('street2', _('Neighborhood for the Seller is required on Standard Invoices')),
+ ('l10n_sa_additional_identification_scheme',
+ _('Additional Identification Scheme is required for the Seller, and must be one of CRN, MOM, MLS, SAG or OTH'),
+ lambda p, v: v in ('CRN', 'MOM', 'MLS', 'SAG', 'OTH')
+ ),
+ ('vat',
+ _('VAT is required when Identification Scheme is set to Tax Identification Number'),
+ lambda p, v: p.l10n_sa_additional_identification_scheme != 'TIN'
+ ),
+ ('state_id', _('State / Country subdivision'))
+ ]
+ return self._l10n_sa_check_partner_missing_info(partner_id, fields_to_check)
+
+ def _l10n_sa_check_buyer_missing_info(self, invoice):
+ """
+ Helper function to check if ZATCA mandated partner fields are missing for the buyer
+ """
+ fields_to_check = []
+ if any(tax.l10n_sa_exemption_reason_code in ('VATEX-SA-HEA', 'VATEX-SA-EDU') for tax in
+ invoice.invoice_line_ids.filtered(
+ lambda line: not line.display_type).tax_ids):
+ fields_to_check += [
+ ('l10n_sa_additional_identification_scheme',
+ _('Additional Identification Scheme is required for the Buyer if tax exemption reason is either '
+ 'VATEX-SA-HEA or VATEX-SA-EDU, and its value must be NAT'), lambda p, v: v == 'NAT'),
+ ('l10n_sa_additional_identification_number',
+ _('Additional Identification Number is required for commercial partners'),
+ lambda p, v: p.l10n_sa_additional_identification_scheme != 'TIN'
+ ),
+ ]
+ elif invoice.commercial_partner_id.l10n_sa_additional_identification_scheme == 'TIN':
+ fields_to_check += [
+ ('vat', _('VAT is required when Identification Scheme is set to Tax Identification Number'))
+ ]
+ if not invoice._l10n_sa_is_simplified() and invoice.partner_id.country_id.code == 'SA':
+ # If the invoice is a non-foreign, Standard (B2B), the Building Number and Neighborhood are required
+ fields_to_check += [
+ ('l10n_sa_edi_building_number', _('Building Number for the Buyer is required on Standard Invoices')),
+ ('street2', _('Neighborhood for the Buyer is required on Standard Invoices')),
+ ]
+ return self._l10n_sa_check_partner_missing_info(invoice.commercial_partner_id, fields_to_check)
+
+ def _l10n_sa_post_zatca_edi(self, invoice): # no batch ensure that there is only one invoice
+ """
+ Post invoice to ZATCA and return a dict of invoices and their success/attachment
+ """
+
+ # Chain integrity check: chain head must have been REALLY posted, and did not time out
+ # When a submission times out, we reset the chain index of the invoice to False, so it has to be submitted again
+ # According to ZATCA, if we end up submitting the same invoice more than once, they will directly reach out
+ # to the taxpayer for clarifications
+ chain_head = invoice.journal_id._l10n_sa_get_last_posted_invoice()
+ if chain_head and chain_head != invoice and not chain_head._l10n_sa_is_in_chain():
+ return {invoice: {
+ 'error': f"ZATCA: Cannot post invoice while chain head ({chain_head.name}) has not been posted",
+ 'blocking_level': 'error',
+ 'response': None,
+ }}
+ xml_content = None
+ if not invoice.l10n_sa_chain_index:
+ # If the Invoice doesn't have a chain index, it means it either has not been submitted before,
+ # or it was submitted and rejected. Either way, we need to assign it a new Chain Index and regenerate
+ # the data that depends on it before submitting (UUID, XML content, signature)
+ invoice.l10n_sa_chain_index = invoice.journal_id._l10n_sa_edi_get_next_chain_index()
+ xml_content = invoice._l10n_sa_generate_unsigned_data()
+
+ # Generate Invoice name for attachment
+ attachment_name = self.env['account.edi.xml.ubl_21.zatca']._export_invoice_filename(invoice)
+
+ # Generate XML, sign it, then submit it to ZATCA
+ response_data, submitted_xml = self._l10n_sa_export_zatca_invoice(invoice, xml_content)
+
+ # Check for submission errors
+ if response_data.get('error'):
+
+ # If the request was rejected, we save the signed xml content as an attachment
+ if response_data.get('rejected'):
+ invoice._l10n_sa_log_results(submitted_xml, response_data, error=True)
+
+ # If the request returned an exception (Timeout, ValueError... etc.) it means we're not sure if the
+ # invoice was successfully cleared/reported, and thus we keep the Index Chain.
+ # Else, we recalculate the submission Index (ICV), UUID, XML content and Signature
+ if not response_data.get('excepted'):
+ invoice.l10n_sa_chain_index = False
+
+ return {
+ invoice: {
+ **response_data,
+ 'response': submitted_xml
+ }
+ }
+
+ # Once submission is done with no errors, check submission status
+ cleared_xml = self._l10n_sa_postprocess_einvoice_submission(invoice, submitted_xml, response_data)
+
+ # Save the submitted/returned invoice XML content once the submission has been completed successfully
+ invoice._l10n_sa_log_results(cleared_xml.encode(), response_data)
+ return {
+ invoice: {
+ 'success': True,
+ 'response': cleared_xml,
+ 'message': '',
+ 'attachment': self.env['ir.attachment'].create({
+ 'name': attachment_name,
+ 'raw': cleared_xml.encode(),
+ 'res_model': 'account.move',
+ 'res_id': invoice.id,
+ 'mimetype': 'application/xml'
+ })
+ }
+ }
+
+ # ====== EDI Format Overrides =======
+
+ def _is_required_for_invoice(self, invoice):
+ """
+ Override to add ZATCA edi checks on required invoices
+ """
+ self.ensure_one()
+ if self.code != 'sa_zatca':
+ return super()._is_required_for_invoice(invoice)
+
+ return invoice.is_sale_document() and invoice.country_code == 'SA'
+
+ def _check_move_configuration(self, invoice):
+ """
+ Override to add ZATCA compliance checks on the Invoice
+ """
+
+ def _set_missing_partner_fields(missing_fields, name):
+ return _("- Please, set the following fields on the %s: %s") % (name, ', '.join(missing_fields))
+
+ journal = invoice.journal_id
+ company = invoice.company_id
+
+ errors = super()._check_move_configuration(invoice)
+ if self.code != 'sa_zatca' or company.country_id.code != 'SA':
+ return errors
+
+ if invoice.commercial_partner_id == invoice.company_id.partner_id.commercial_partner_id:
+ errors.append(_("- You cannot post invoices where the Seller is the Buyer"))
+
+ if not all(line.tax_ids for line in invoice.invoice_line_ids.filtered(lambda line: not line.display_type)):
+ errors.append(_("- Invoice lines should have at least one Tax applied."))
+
+ if not journal._l10n_sa_ready_to_submit_einvoices():
+ errors.append(
+ _("- Finish the Onboarding procees for journal %s by requesting the CSIDs and completing the checks.") % journal.name)
+
+ if not company._l10n_sa_check_organization_unit():
+ errors.append(
+ _("- The company VAT identification must contain 15 digits, with the first and last digits being '3' as per the BR-KSA-39 and BR-KSA-40 of ZATCA KSA business rule."))
+ if not company.sudo().l10n_sa_private_key:
+ errors.append(
+ _("- No Private Key was generated for company %s. A Private Key is mandatory in order to generate Certificate Signing Requests (CSR).") % company.name)
+ if not journal.l10n_sa_serial_number:
+ errors.append(
+ _("- No Serial Number was assigned for journal %s. A Serial Number is mandatory in order to generate Certificate Signing Requests (CSR).") % journal.name)
+
+ supplier_missing_info = self._l10n_sa_check_seller_missing_info(invoice)
+ customer_missing_info = self._l10n_sa_check_buyer_missing_info(invoice)
+
+ if supplier_missing_info:
+ errors.append(_set_missing_partner_fields(supplier_missing_info, _("Supplier")))
+ if customer_missing_info:
+ errors.append(_set_missing_partner_fields(customer_missing_info, _("Customer")))
+ if invoice.invoice_date > date.today():
+ errors.append(_("- Please, make sure the invoice date is set to either the same as or before Today."))
+ if invoice.move_type in ('in_refund', 'out_refund') and not invoice._l10n_sa_check_refund_reason():
+ errors.append(
+ _("- Please, make sure both the Reversed Entry and the Reversal Reason are specified when confirming a Credit/Debit note"))
+ return errors
+
+ def _needs_web_services(self):
+ """
+ Override to add a check on edi document format code
+ """
+ self.ensure_one()
+ return self.code == 'sa_zatca' or super()._needs_web_services()
+
+ def _is_compatible_with_journal(self, journal):
+ """
+ Override to add a check on journal type & country code (SA)
+ """
+ self.ensure_one()
+ if self.code != 'sa_zatca':
+ return super()._is_compatible_with_journal(journal)
+ return journal.type == 'sale' and journal.country_code == 'SA'
+
+ def _l10n_sa_get_invoice_content_edi(self, invoice):
+ """
+ Return contents of the submitted UBL file or generate it if the invoice has not been submitted yet
+ """
+ doc = invoice.edi_document_ids.filtered(lambda d: d.edi_format_id.code == 'sa_zatca' and d.state == 'sent')
+ if doc is not None and doc.attachment_id.datas:
+ return {invoice: {'xml_file': doc.attachment_id.datas.decode()}}
+ return {invoice: {'xml_file': self._l10n_sa_generate_zatca_template(invoice)}}
+
+ def _get_move_applicability(self, move):
+ # EXTENDS account_edi
+ self.ensure_one()
+ if self.code != 'sa_zatca' or move.country_code != 'SA' or move.move_type not in ('out_invoice', 'out_refund'):
+ return super()._get_move_applicability(move)
+
+ return {
+ 'post': self._l10n_sa_post_zatca_edi,
+ 'edi_content': self._l10n_sa_get_invoice_content_edi,
+ }
diff --git a/addons/l10n_sa_edi/models/account_edi_xml_ubl_21_zatca.py b/addons/l10n_sa_edi/models/account_edi_xml_ubl_21_zatca.py
new file mode 100644
index 00000000000..de627c482b1
--- /dev/null
+++ b/addons/l10n_sa_edi/models/account_edi_xml_ubl_21_zatca.py
@@ -0,0 +1,408 @@
+# -*- coding: utf-8 -*-
+from hashlib import sha256
+from base64 import b64encode
+from lxml import etree
+from odoo import models, fields
+from odoo.modules.module import get_module_resource
+import re
+
+TAX_EXEMPTION_CODES = ['VATEX-SA-29', 'VATEX-SA-29-7', 'VATEX-SA-30']
+TAX_ZERO_RATE_CODES = ['VATEX-SA-32', 'VATEX-SA-33', 'VATEX-SA-34-1', 'VATEX-SA-34-2', 'VATEX-SA-34-3', 'VATEX-SA-34-4',
+ 'VATEX-SA-34-5', 'VATEX-SA-35', 'VATEX-SA-36', 'VATEX-SA-EDU', 'VATEX-SA-HEA']
+
+PAYMENT_MEANS_CODE = {
+ 'bank': 42,
+ 'card': 48,
+ 'cash': 10,
+ 'transfer': 30,
+ 'unknown': 1
+}
+
+
+class AccountEdiXmlUBL21Zatca(models.AbstractModel):
+ _name = "account.edi.xml.ubl_21.zatca"
+ _inherit = 'account.edi.xml.ubl_21'
+ _description = "UBL 2.1 (ZATCA)"
+
+ def _l10n_sa_get_namespaces(self):
+ """
+ Namespaces used in the final UBL declaration, required to canonalize the finalized XML document of the Invoice
+ """
+ return {
+ 'cac': 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2',
+ 'cbc': 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
+ 'ext': 'urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2',
+ 'sig': 'urn:oasis:names:specification:ubl:schema:xsd:CommonSignatureComponents-2',
+ 'sac': 'urn:oasis:names:specification:ubl:schema:xsd:SignatureAggregateComponents-2',
+ 'sbc': 'urn:oasis:names:specification:ubl:schema:xsd:SignatureBasicComponents-2',
+ 'ds': 'http://www.w3.org/2000/09/xmldsig#',
+ 'xades': 'http://uri.etsi.org/01903/v1.3.2#'
+ }
+
+ def _l10n_sa_generate_invoice_xml_sha(self, xml_content):
+ """
+ Transform, canonicalize then hash the invoice xml content using the SHA256 algorithm,
+ then return the hashed content
+ """
+
+ def _canonicalize_xml(content):
+ """
+ Canonicalize XML content using the c14n method. The specs mention using the c14n11 canonicalization,
+ which is simply calling etree.tostring and setting the method argument to 'c14n'. There are minor
+ differences between c14n11 and c14n canonicalization algorithms, but for the purpose of ZATCA signing,
+ c14n is enough
+ """
+ return etree.tostring(content, method="c14n", exclusive=False, with_comments=False,
+ inclusive_ns_prefixes=self._l10n_sa_get_namespaces())
+
+ def _transform_and_canonicalize_xml(content):
+ """ Transform XML content to remove certain elements and signatures using an XSL template """
+ invoice_xsl = etree.parse(get_module_resource('l10n_sa_edi', 'data', 'pre-hash_invoice.xsl'))
+ transform = etree.XSLT(invoice_xsl)
+ return _canonicalize_xml(transform(content))
+
+ root = etree.fromstring(xml_content)
+ # Transform & canonicalize the XML content
+ transformed_xml = _transform_and_canonicalize_xml(root)
+ # Get the SHA256 hashed value of the XML content
+ return sha256(transformed_xml)
+
+ def _l10n_sa_generate_invoice_xml_hash(self, xml_content, mode='hexdigest'):
+ """
+ Generate the b64 encoded sha256 hash of a given xml string:
+ - First: Transform the xml content using a pre-hash_invoice.xsl file
+ - Second: Canonicalize the transformed xml content using the c14n method
+ - Third: hash the canonicalized content using the sha256 algorithm then encode it into b64 format
+ """
+ xml_sha = self._l10n_sa_generate_invoice_xml_sha(xml_content)
+ if mode == 'hexdigest':
+ xml_hash = xml_sha.hexdigest().encode()
+ elif mode == 'digest':
+ xml_hash = xml_sha.digest()
+ return b64encode(xml_hash)
+
+ def _l10n_sa_get_previous_invoice_hash(self, invoice):
+ """ Function that returns the Base 64 encoded SHA256 hash of the previously submitted invoice """
+ if invoice.company_id.l10n_sa_api_mode == 'sandbox' or not invoice.journal_id.l10n_sa_latest_submission_hash:
+ # If no invoice, or if using Sandbox, return the b64 encoded SHA256 value of the '0' character
+ return "NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ=="
+ return invoice.journal_id.l10n_sa_latest_submission_hash
+
+ def _get_delivery_vals_list(self, invoice):
+ """ Override to include/update values specific to ZATCA's UBL 2.1 specs """
+ shipping_address = invoice.partner_shipping_id
+ return [{'actual_delivery_date': invoice.l10n_sa_delivery_date,
+ 'delivery_address_vals': self._get_partner_address_vals(shipping_address) if shipping_address else {},}]
+
+ def _get_partner_party_identification_vals_list(self, partner):
+ """ Override to include/update values specific to ZATCA's UBL 2.1 specs """
+ return [{
+ 'id_attrs': {'schemeID': partner.l10n_sa_additional_identification_scheme},
+ 'id': partner.l10n_sa_additional_identification_number if partner.l10n_sa_additional_identification_scheme != 'TIN' else partner.vat
+ }]
+
+ def _l10n_sa_get_payment_means_code(self, invoice):
+ """ Return payment means code to be used to set the value on the XML file """
+ return 'unknown'
+
+ def _get_invoice_payment_means_vals_list(self, invoice):
+ """ Override to include/update values specific to ZATCA's UBL 2.1 specs """
+ res = super()._get_invoice_payment_means_vals_list(invoice)
+ res[0]['payment_means_code'] = PAYMENT_MEANS_CODE[self._l10n_sa_get_payment_means_code(invoice)]
+ res[0]['payment_means_code_attrs'] = {'listID': 'UN/ECE 4461'}
+ res[0]['adjustment_reason'] = invoice.ref
+ return res
+
+ def _get_partner_address_vals(self, partner):
+ """ Override to include/update values specific to ZATCA's UBL 2.1 specs """
+ return {
+ **super()._get_partner_address_vals(partner),
+ 'building_number': partner.l10n_sa_edi_building_number,
+ 'neighborhood': partner.street2,
+ 'plot_identification': partner.l10n_sa_edi_plot_identification,
+ }
+
+ def _export_invoice_filename(self, invoice):
+ """
+ Generate the name of the invoice XML file according to ZATCA business rules:
+ Seller Vat Number (BT-31), Date (BT-2), Time (KSA-25), Invoice Number (BT-1)
+ """
+ vat = invoice.company_id.partner_id.commercial_partner_id.vat
+ invoice_number = re.sub("[^a-zA-Z0-9 -]", "-", invoice.name)
+ invoice_date = fields.Datetime.context_timestamp(self.with_context(tz='Asia/Riyadh'), invoice.l10n_sa_confirmation_datetime)
+ return '%s_%s_%s.xml' % (vat, invoice_date.strftime('%Y%m%dT%H%M%S'), invoice_number)
+
+ def _l10n_sa_get_invoice_transaction_code(self, invoice):
+ """
+ Returns the transaction code string to be inserted in the UBL file follows the following format:
+ - NNPNESB, in compliance with KSA Business Rule KSA-2, where:
+ - NN (positions 1 and 2) = invoice subtype:
+ - 01 for tax invoice
+ - 02 for simplified tax invoice
+ - E (position 5) = Exports invoice transaction, 0 for false, 1 for true
+ """
+ return '0%s00%s00' % (
+ '2' if invoice._l10n_sa_is_simplified() else '1',
+ '1' if invoice.commercial_partner_id.country_id != invoice.company_id.country_id and not invoice._l10n_sa_is_simplified() else '0'
+ )
+
+ def _l10n_sa_get_invoice_type(self, invoice):
+ """
+ Returns the invoice type string to be inserted in the UBL file
+ - 383: Debit Note
+ - 381: Credit Note
+ - 388: Invoice
+ """
+ return 383 if invoice.debit_origin_id else 381 if invoice.move_type == 'out_refund' else 388
+
+ def _l10n_sa_get_billing_reference_vals(self, invoice):
+ """ Get the billing reference vals required to render the BillingReference for credit/debit notes """
+ if self._l10n_sa_get_invoice_type(invoice) != 388:
+ return {
+ 'id': (invoice.reversed_entry_id.name or invoice.ref) if invoice.move_type == 'out_refund' else invoice.debit_origin_id.name,
+ 'issue_date': None,
+ }
+ return {}
+
+ def _get_partner_party_tax_scheme_vals_list(self, partner, role):
+ """
+ Override to return an empty list if the partner is a customer and their country is not KSA.
+ This is according to KSA Business Rule BR-KSA-46 which states that in the case of Export Invoices,
+ the buyer VAT registration number or buyer group VAT registration number must not exist in the Invoice
+ """
+ if role != 'customer' or partner.country_id.code == 'SA':
+ return super()._get_partner_party_tax_scheme_vals_list(partner, role)
+ return []
+
+ def _apply_invoice_tax_filter(self, base_line, tax_values):
+ """ Override to filter out withholding tax """
+ tax_id = self.env['account.tax'].browse(tax_values['id'])
+ res = not tax_id.l10n_sa_is_retention
+ # If the move that is being sent is not a down payment invoice, and the sale module is installed
+ # we need to make sure the line is neither retention, nor a down payment line
+ if not base_line['record'].move_id._is_downpayment():
+ return not tax_id.l10n_sa_is_retention and not base_line['record']._get_downpayment_lines()
+ return res
+
+ def _apply_invoice_line_filter(self, invoice_line):
+ """ Override to filter out down payment lines """
+ if not invoice_line.move_id._is_downpayment():
+ return not invoice_line._get_downpayment_lines()
+ return True
+
+ def _l10n_sa_get_prepaid_amount(self, invoice, vals):
+ """ Calculate the down-payment amount according to ZATCA rules """
+ downpayment_lines = False if invoice._is_downpayment() else invoice.line_ids.filtered(lambda l: l._get_downpayment_lines())
+ if downpayment_lines:
+ tax_vals = invoice._prepare_edi_tax_details(filter_to_apply=lambda l, t: not self.env['account.tax'].browse(t['id']).l10n_sa_is_retention)
+ base_amount = abs(sum(tax_vals['tax_details_per_record'][l]['base_amount_currency'] for l in downpayment_lines))
+ tax_amount = abs(sum(tax_vals['tax_details_per_record'][l]['tax_amount_currency'] for l in downpayment_lines))
+ return {
+ 'total_amount': base_amount + tax_amount,
+ 'base_amount': base_amount,
+ 'tax_amount': tax_amount
+ }
+
+ def _l10n_sa_get_monetary_vals(self, invoice, vals):
+ """ Calculate the invoice monteray amount values, including prepaid amounts (down payment) """
+ # We use base_amount_currency + tax_amount_currency instead of amount_total because we do not want to include
+ # withholding tax amounts in our calculations
+ total_amount = abs(vals['taxes_vals']['base_amount_currency'] + vals['taxes_vals']['tax_amount_currency'])
+
+ tax_inclusive_amount = total_amount
+ tax_exclusive_amount = abs(vals['taxes_vals']['base_amount_currency'])
+ prepaid_amount = 0
+ payable_amount = total_amount
+
+ # - When we calculate the tax values, we filter out taxes and invoice lines linked to downpayments.
+ # As such, when we calculate the TaxInclusiveAmount, it already accounts for the tax amount of the downpayment
+ # Same goes for the TaxExclusiveAmount, and we do not need to add the Tax amount of the downpayment
+ # - The payable amount does not account for the tax amount of the downpayment, so we add it
+ downpayment_vals = self._l10n_sa_get_prepaid_amount(invoice, vals)
+
+ if downpayment_vals:
+ # Makes no sense, but according to ZATCA, if there is a downpayment, the TotalInclusiveAmount
+ # should include the total amount of the invoice (including downpayment amount) PLUS the downpayment
+ # total amount, AGAIN.
+ prepaid_amount = tax_inclusive_amount + downpayment_vals['total_amount']
+ payable_amount = - downpayment_vals['total_amount']
+
+ return {
+ 'tax_inclusive_amount': tax_inclusive_amount,
+ 'tax_exclusive_amount': tax_exclusive_amount,
+ 'prepaid_amount': prepaid_amount,
+ 'payable_amount': payable_amount
+ }
+
+ def _get_tax_category_list(self, invoice, taxes):
+ """ Override to filter out withholding taxes """
+ non_retention_taxes = taxes.filtered(lambda t: not t.l10n_sa_is_retention)
+ return super()._get_tax_category_list(invoice, non_retention_taxes)
+
+ def _export_invoice_vals(self, invoice):
+ """ Override to include/update values specific to ZATCA's UBL 2.1 specs """
+ vals = super()._export_invoice_vals(invoice)
+
+ vals.update({
+ 'main_template': 'account_edi_ubl_cii.ubl_20_Invoice',
+ 'InvoiceType_template': 'l10n_sa_edi.ubl_21_InvoiceType_zatca',
+ 'InvoiceLineType_template': 'l10n_sa_edi.ubl_21_InvoiceLineType_zatca',
+ 'AddressType_template': 'l10n_sa_edi.ubl_21_AddressType_zatca',
+ 'PartyType_template': 'l10n_sa_edi.ubl_21_PartyType_zatca',
+ 'TaxTotalType_template': 'l10n_sa_edi.ubl_21_TaxTotalType_zatca',
+ 'PaymentMeansType_template': 'l10n_sa_edi.ubl_21_PaymentMeansType_zatca',
+ })
+
+ vals['vals'].update({
+ 'profile_id': 'reporting:1.0',
+ 'invoice_type_code_attrs': {'name': self._l10n_sa_get_invoice_transaction_code(invoice)},
+ 'invoice_type_code': self._l10n_sa_get_invoice_type(invoice),
+ 'issue_date': fields.Datetime.context_timestamp(self.with_context(tz='Asia/Riyadh'),
+ invoice.l10n_sa_confirmation_datetime),
+ 'previous_invoice_hash': self._l10n_sa_get_previous_invoice_hash(invoice),
+ 'billing_reference_vals': self._l10n_sa_get_billing_reference_vals(invoice),
+ 'tax_total_vals': self._l10n_sa_get_additional_tax_total_vals(invoice, vals),
+ # Due date is not required for ZATCA UBL 2.1
+ 'due_date': None,
+ })
+
+ vals['vals']['legal_monetary_total_vals'].update(self._l10n_sa_get_monetary_vals(invoice, vals))
+
+ return vals
+
+ def _l10n_sa_get_additional_tax_total_vals(self, invoice, vals):
+ """
+ For ZATCA, an additional TaxTotal element needs to be included in the UBL file
+ (Only for the Invoice, not the lines)
+
+ If the invoice is in a different currency from the one set on the company (SAR), then the additional
+ TaxAmount element needs to hold the tax amount converted to the company's currency.
+
+ Business Rules: BT-110 & BT-111
+ """
+ curr_amount = abs(vals['taxes_vals']['tax_amount_currency'])
+ if invoice.currency_id != invoice.company_currency_id:
+ curr_amount = abs(vals['taxes_vals']['tax_amount'])
+ return vals['vals']['tax_total_vals'] + [{
+ 'currency': invoice.company_currency_id,
+ 'currency_dp': invoice.company_currency_id.decimal_places,
+ 'tax_amount': curr_amount,
+ }]
+
+ def _get_invoice_line_item_vals(self, line, taxes_vals):
+ """ Override to include/update values specific to ZATCA's UBL 2.1 specs """
+ vals = super()._get_invoice_line_item_vals(line, taxes_vals)
+ vals['sellers_item_identification_vals'] = {'id': line.product_id.code or line.product_id.default_code}
+ return vals
+
+ def _l10n_sa_get_line_prepayment_vals(self, line, taxes_vals):
+ """
+ If an invoice line is linked to a down payment invoice, we need to return the proper values
+ to be included in the UBL
+ """
+ if not line.move_id._is_downpayment() and line.sale_line_ids and all(sale_line.is_downpayment for sale_line in line.sale_line_ids):
+ prepayment_move_id = line.sale_line_ids.invoice_lines.move_id.filtered(lambda m: m._is_downpayment())
+ return {
+ 'prepayment_id': prepayment_move_id.name,
+ 'issue_date': fields.Datetime.context_timestamp(self.with_context(tz='Asia/Riyadh'),
+ prepayment_move_id.l10n_sa_confirmation_datetime),
+ 'document_type_code': 386
+ }
+ return {}
+
+ def _get_invoice_line_vals(self, line, taxes_vals):
+ """ Override to include/update values specific to ZATCA's UBL 2.1 specs """
+
+ def grouping_key_generator(base_line, tax_values):
+ tax = tax_values['tax_repartition_line'].tax_id
+ tax_category_vals = self._get_tax_category_list(line.move_id, tax)[0]
+ grouping_key = {
+ 'tax_category_id': tax_category_vals['id'],
+ 'tax_category_percent': tax_category_vals['percent'],
+ '_tax_category_vals_': tax_category_vals,
+ 'tax_amount_type': tax.amount_type,
+ }
+ if tax.amount_type == 'fixed':
+ grouping_key['tax_name'] = tax.name
+ return grouping_key
+
+ if not line.move_id._is_downpayment() and line._get_downpayment_lines():
+ # When we initially calculate the taxes_vals, we filter out the down payment lines, which means we have no
+ # values to set in the TaxableAmount and TaxAmount nodes on the InvoiceLine for the down payment.
+ # This means ZATCA will return a warning message for the BR-KSA-80 rule since it cannot calculate the
+ # TaxableAmount and the TaxAmount nodes correctly. To avoid this, we re-caclculate the taxes_vals just before
+ # we set the values for the down payment line, and we do not pass any filters to the _prepare_edi_tax_details
+ # method
+ line_taxes = line.move_id._prepare_edi_tax_details(grouping_key_generator=grouping_key_generator)
+ taxes_vals = line_taxes['tax_details_per_record'][line]
+
+ line_vals = super()._get_invoice_line_vals(line, taxes_vals)
+ total_amount_sa = abs(taxes_vals['tax_amount_currency'] + taxes_vals['base_amount_currency'])
+ extension_amount = abs(line_vals['line_extension_amount'])
+ if not line.move_id._is_downpayment() and line._get_downpayment_lines():
+ total_amount_sa = extension_amount = 0
+ line_vals['price_vals']['price_amount'] = 0
+ line_vals['tax_total_vals'][0]['tax_amount'] = 0
+ line_vals['prepayment_vals'] = self._l10n_sa_get_line_prepayment_vals(line, taxes_vals)
+ line_vals['tax_total_vals'][0]['total_amount_sa'] = total_amount_sa
+ line_vals['invoiced_quantity'] = abs(line_vals['invoiced_quantity'])
+ line_vals['line_extension_amount'] = extension_amount
+
+ return line_vals
+
+ def _get_invoice_tax_totals_vals_list(self, invoice, taxes_vals):
+ """
+ Override to include/update values specific to ZATCA's UBL 2.1 specs.
+ In this case, we make sure the tax amounts are always absolute (no negative values)
+ """
+ res = [{
+ 'currency': invoice.currency_id,
+ 'currency_dp': invoice.currency_id.decimal_places,
+ 'tax_amount': abs(taxes_vals['tax_amount_currency']),
+ 'tax_subtotal_vals': [{
+ 'currency': invoice.currency_id,
+ 'currency_dp': invoice.currency_id.decimal_places,
+ 'taxable_amount': abs(vals['base_amount_currency']),
+ 'tax_amount': abs(vals['tax_amount_currency']),
+ 'percent': vals['_tax_category_vals_']['percent'],
+ 'tax_category_vals': vals['_tax_category_vals_'],
+ } for vals in taxes_vals['tax_details'].values()],
+ }]
+ return res
+
+ def _get_tax_unece_codes(self, invoice, tax):
+ """ Override to include/update values specific to ZATCA's UBL 2.1 specs """
+
+ def _exemption_reason(code, reason):
+ return {
+ 'tax_category_code': code,
+ 'tax_exemption_reason_code': reason,
+ 'tax_exemption_reason': exemption_codes[reason].split(reason)[1].lstrip(),
+ }
+
+ supplier = invoice.company_id.partner_id.commercial_partner_id
+ customer = invoice.commercial_partner_id
+ if supplier.country_id == customer.country_id and supplier.country_id.code == 'SA':
+ if not tax or tax.amount == 0:
+ exemption_codes = dict(tax._fields["l10n_sa_exemption_reason_code"]._description_selection(self.env))
+ if tax.l10n_sa_exemption_reason_code in TAX_EXEMPTION_CODES:
+ return _exemption_reason('E', tax.l10n_sa_exemption_reason_code)
+ elif tax.l10n_sa_exemption_reason_code in TAX_ZERO_RATE_CODES:
+ return _exemption_reason('Z', tax.l10n_sa_exemption_reason_code)
+ else:
+ return {
+ 'tax_category_code': 'O',
+ 'tax_exemption_reason_code': 'Not subject to VAT',
+ 'tax_exemption_reason': 'Not subject to VAT',
+ }
+ else:
+ return {
+ 'tax_category_code': 'S',
+ 'tax_exemption_reason_code': None,
+ 'tax_exemption_reason': None,
+ }
+ return super()._get_tax_unece_codes(invoice, tax)
+
+ def _get_invoice_payment_terms_vals_list(self, invoice):
+ """ Override to include/update values specific to ZATCA's UBL 2.1 specs """
+ return []
diff --git a/addons/l10n_sa_edi/models/account_journal.py b/addons/l10n_sa_edi/models/account_journal.py
new file mode 100644
index 00000000000..e7ca06eb467
--- /dev/null
+++ b/addons/l10n_sa_edi/models/account_journal.py
@@ -0,0 +1,624 @@
+import json
+import requests
+from markupsafe import Markup
+from lxml import etree
+from datetime import datetime
+from base64 import b64encode, b64decode
+from odoo import models, fields, service, _, api
+from odoo.exceptions import UserError
+from odoo.modules.module import get_module_resource
+from requests.exceptions import HTTPError, RequestException
+from cryptography import x509
+from cryptography.x509 import ObjectIdentifier, load_der_x509_certificate
+from cryptography.x509.oid import NameOID
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.serialization import Encoding, load_pem_private_key
+from urllib.parse import urljoin
+
+ZATCA_API_URLS = {
+ "sandbox": "https://gw-fatoora.zatca.gov.sa/e-invoicing/developer-portal/",
+ "preprod": "https://gw-fatoora.zatca.gov.sa/e-invoicing/simulation/",
+ "prod": "https://gw-fatoora.zatca.gov.sa/e-invoicing/core/",
+ "apis": {
+ "ccsid": "compliance",
+ "pcsid": "production/csids",
+ "compliance": "compliance/invoices",
+ "reporting": "invoices/reporting/single",
+ "clearance": "invoices/clearance/single",
+ }
+}
+
+CERT_TEMPLATE_NAME = {
+ 'prod': b'\x0c\x12ZATCA-Code-Signing',
+ 'sandbox': b'\x13\x15PREZATCA-Code-Signing',
+ 'preprod': b'\x13\x15PREZATCA-Code-Signing',
+}
+# This SANDBOX_AUTH is only used for testing purposes, and is shared to all users of the sandbox environment
+SANDBOX_AUTH = {
+ 'binarySecurityToken': "TUlJRDFEQ0NBM21nQXdJQkFnSVRid0FBZTNVQVlWVTM0SS8rNVFBQkFBQjdkVEFLQmdncWhrak9QUVFEQWpCak1SVXdFd1lLQ1pJbWlaUHlMR1FCR1JZRmJHOWpZV3d4RXpBUkJnb0praWFKay9Jc1pBRVpGZ05uYjNZeEZ6QVZCZ29Ka2lhSmsvSXNaQUVaRmdkbGVIUm5ZWHAwTVJ3d0dnWURWUVFERXhOVVUxcEZTVTVXVDBsRFJTMVRkV0pEUVMweE1CNFhEVEl5TURZeE1qRTNOREExTWxvWERUSTBNRFl4TVRFM05EQTFNbG93U1RFTE1Ba0dBMVVFQmhNQ1UwRXhEakFNQmdOVkJBb1RCV0ZuYVd4bE1SWXdGQVlEVlFRTEV3MW9ZWGxoSUhsaFoyaHRiM1Z5TVJJd0VBWURWUVFERXdreE1qY3VNQzR3TGpFd1ZqQVFCZ2NxaGtqT1BRSUJCZ1VyZ1FRQUNnTkNBQVRUQUs5bHJUVmtvOXJrcTZaWWNjOUhEUlpQNGI5UzR6QTRLbTdZWEorc25UVmhMa3pVMEhzbVNYOVVuOGpEaFJUT0hES2FmdDhDL3V1VVk5MzR2dU1ObzRJQ0p6Q0NBaU13Z1lnR0ExVWRFUVNCZ0RCK3BId3dlakViTUJrR0ExVUVCQXdTTVMxb1lYbGhmREl0TWpNMGZETXRNVEV5TVI4d0hRWUtDWkltaVpQeUxHUUJBUXdQTXpBd01EYzFOVGc0TnpBd01EQXpNUTB3Q3dZRFZRUU1EQVF4TVRBd01SRXdEd1lEVlFRYURBaGFZWFJqWVNBeE1qRVlNQllHQTFVRUR3d1BSbTl2WkNCQ2RYTnphVzVsYzNNek1CMEdBMVVkRGdRV0JCU2dtSVdENmJQZmJiS2ttVHdPSlJYdkliSDlIakFmQmdOVkhTTUVHREFXZ0JSMllJejdCcUNzWjFjMW5jK2FyS2NybVRXMUx6Qk9CZ05WSFI4RVJ6QkZNRU9nUWFBL2hqMW9kSFJ3T2k4dmRITjBZM0pzTG5waGRHTmhMbWR2ZGk1ellTOURaWEowUlc1eWIyeHNMMVJUV2tWSlRsWlBTVU5GTFZOMVlrTkJMVEV1WTNKc01JR3RCZ2dyQmdFRkJRY0JBUVNCb0RDQm5UQnVCZ2dyQmdFRkJRY3dBWVppYUhSMGNEb3ZMM1J6ZEdOeWJDNTZZWFJqWVM1bmIzWXVjMkV2UTJWeWRFVnVjbTlzYkM5VVUxcEZhVzUyYjJsalpWTkRRVEV1WlhoMFoyRjZkQzVuYjNZdWJHOWpZV3hmVkZOYVJVbE9WazlKUTBVdFUzVmlRMEV0TVNneEtTNWpjblF3S3dZSUt3WUJCUVVITUFHR0gyaDBkSEE2THk5MGMzUmpjbXd1ZW1GMFkyRXVaMjkyTG5OaEwyOWpjM0F3RGdZRFZSMFBBUUgvQkFRREFnZUFNQjBHQTFVZEpRUVdNQlFHQ0NzR0FRVUZCd01DQmdnckJnRUZCUWNEQXpBbkJna3JCZ0VFQVlJM0ZRb0VHakFZTUFvR0NDc0dBUVVGQndNQ01Bb0dDQ3NHQVFVRkJ3TURNQW9HQ0NxR1NNNDlCQU1DQTBrQU1FWUNJUUNWd0RNY3E2UE8rTWNtc0JYVXovdjFHZGhHcDdycVNhMkF4VEtTdjgzOElBSWhBT0JOREJ0OSszRFNsaWpvVmZ4enJkRGg1MjhXQzM3c21FZG9HV1ZyU3BHMQ==",
+ 'secret': "Xlj15LyMCgSC66ObnEO/qVPfhSbs3kDTjWnGheYhfSs="
+}
+
+
+class AccountJournal(models.Model):
+ _inherit = 'account.journal'
+
+ """
+ In order to clear/report an invoice through the ZATCA API, we need to onboard each journal by following
+ three steps:
+
+ STEP 1:
+ Make a call to the Compliance CSID API '/compliance'.
+ This will return three things:
+ - X509 Compliance Cryptographic Stamp Identifier (CCSID/Certificate)
+ - Password (Secret)
+ - Compliance Request ID
+ STEP 2:
+ Make a call to the Compliance Checks API '/compliance/invoices', by passing the hashed xml content
+ of the files available in the tests/compliance folder. This will check if the provided
+ Standard/Simplified Invoices comply with UBL 2.1 standards in line with ZATCA specifications
+ STEP 3:
+ Make a call to the Production CSID API '/production/csids' including the Compliance Certificate,
+ Password and Request ID from STEP 1.
+ This will return three things:
+ - X509 Production Certificate
+ - Password (Secret)
+ - Production Request ID
+ """
+
+ l10n_sa_csr = fields.Binary(attachment=True, copy=False, groups="base.group_system",
+ help="The Certificate Signing Request that is submitted to the Compliance API")
+ l10n_sa_csr_errors = fields.Html("Onboarding Errors", copy=False)
+
+ l10n_sa_compliance_csid_json = fields.Char("CCSID JSON", copy=False, groups="base.group_system",
+ help="Compliance CSID data received from the Compliance CSID API "
+ "in dumped json format")
+ l10n_sa_production_csid_json = fields.Char("PCSID JSON", copy=False, groups="base.group_system",
+ help="Production CSID data received from the Production CSID API "
+ "in dumped json format")
+ l10n_sa_production_csid_validity = fields.Datetime("PCSID Expiration", help="Production CSID expiration date",
+ compute="_l10n_sa_compute_production_csid_validity", store=True)
+ l10n_sa_compliance_checks_passed = fields.Boolean("Compliance Checks Done", default=False, copy=False,
+ help="Specifies if the Compliance Checks have been completed successfully")
+
+ l10n_sa_chain_sequence_id = fields.Many2one('ir.sequence', string='ZATCA account.move chain sequence',
+ readonly=True, copy=False)
+
+ l10n_sa_serial_number = fields.Char("Serial Number", copy=False,
+ help="The serial number of the Taxpayer solution unit. Provided by ZATCA")
+
+ l10n_sa_latest_submission_hash = fields.Char("Latest Submission Hash", copy=False,
+ help="Hash of the latest submitted invoice to be used as the Previous Invoice Hash (KSA-13)")
+
+ # ====== Utility Functions =======
+
+ def _l10n_sa_ready_to_submit_einvoices(self):
+ """
+ Helper function to know if the required CSIDs have been obtained, and the compliance checks have been
+ completed
+ """
+ self.ensure_one()
+ return self.sudo().l10n_sa_production_csid_json
+
+ # ====== CSR Generation =======
+
+ def _l10n_sa_csr_required_fields(self):
+ """ Return the list of fields required to generate a valid CSR as per ZATCA requirements """
+ return ['l10n_sa_private_key', 'vat', 'name', 'city', 'country_id', 'state_id']
+
+ def _l10n_sa_get_csr_str(self):
+ """
+ Return s string representation of a ZATCA compliant CSR that will be sent to the Compliance API in order to get back
+ a signed X509 certificate
+ """
+ self.ensure_one()
+
+ def _encode(s):
+ """
+ Some of the information included in the CSR could be in arabic, and thus needs to be encoded in a
+ specific format in order to be compliant with the ZATCA CCSID/PCSID APIs
+ """
+ return s.encode().decode('CP1252')
+
+ company_id = self.company_id
+ version_info = service.common.exp_version()
+ builder = x509.CertificateSigningRequestBuilder()
+ subject_names = (
+ # Country Name
+ (NameOID.COUNTRY_NAME, company_id.country_id.code),
+ # Organization Unit Name
+ (NameOID.ORGANIZATIONAL_UNIT_NAME, (company_id.vat or '')[:10]),
+ # Organization Name
+ (NameOID.ORGANIZATION_NAME, _encode(company_id.name)),
+ # Subject Common Name
+ (NameOID.COMMON_NAME, _encode(company_id.name)),
+ # Organization Identifier
+ (ObjectIdentifier('2.5.4.97'), company_id.vat),
+ # State/Province Name
+ (NameOID.STATE_OR_PROVINCE_NAME, _encode(company_id.state_id.name)),
+ # Locality Name
+ (NameOID.LOCALITY_NAME, _encode(company_id.city)),
+ )
+ # The CertificateSigningRequestBuilder instances are immutable, which is why everytime we modify one,
+ # we have to assign it back to itself to keep track of the changes
+ builder = builder.subject_name(x509.Name([
+ x509.NameAttribute(n[0], u'%s' % n[1]) for n in subject_names
+ ]))
+
+ x509_alt_names_extension = x509.SubjectAlternativeName([
+ x509.DirectoryName(x509.Name([
+ # EGS Serial Number. Manufacturer or Solution Provider Name, Model or Version and Serial Number.
+ # To be written in the following format: "1-... |2-... |3-..."
+ x509.NameAttribute(ObjectIdentifier('2.5.4.4'), '1-Odoo|2-%s|3-%s' % (
+ version_info['server_version_info'][0], self.l10n_sa_serial_number)),
+ # Organisation Identifier (UID)
+ x509.NameAttribute(NameOID.USER_ID, company_id.vat),
+ # Invoice Type. 4-digit numerical input using 0 & 1
+ x509.NameAttribute(NameOID.TITLE, company_id._l10n_sa_get_csr_invoice_type()),
+ # Location
+ x509.NameAttribute(ObjectIdentifier('2.5.4.26'), _encode(company_id.street)),
+ # Industry
+ x509.NameAttribute(ObjectIdentifier('2.5.4.15'),
+ _encode(company_id.partner_id.industry_id.name or 'Other')),
+ ]))
+ ])
+
+ x509_extensions = (
+ # Add Certificate template name extension
+ (x509.UnrecognizedExtension(ObjectIdentifier('1.3.6.1.4.1.311.20.2'),
+ CERT_TEMPLATE_NAME[company_id.l10n_sa_api_mode]), False),
+ # Add alternative names extension
+ (x509_alt_names_extension, False),
+ )
+
+ for ext in x509_extensions:
+ builder = builder.add_extension(ext[0], critical=ext[1])
+
+ private_key = load_pem_private_key(company_id.l10n_sa_private_key, password=None, backend=default_backend())
+ request = builder.sign(private_key, hashes.SHA256(), default_backend())
+
+ return b64encode(request.public_bytes(Encoding.PEM)).decode()
+
+ def _l10n_sa_generate_csr(self):
+ """
+ Generate a CSR for the Journal to be used for the Onboarding process and Invoice submissions
+ """
+ self.ensure_one()
+ if any(not self.company_id[f] for f in self._l10n_sa_csr_required_fields()):
+ raise UserError(_("Please, make sure all the following fields have been correctly set on the Company: \n")
+ + "\n".join(
+ " - %s" % self.company_id._fields[f].string for f in self._l10n_sa_csr_required_fields() if
+ not self.company_id[f]))
+ self._l10n_sa_reset_certificates()
+ self.l10n_sa_csr = self._l10n_sa_get_csr_str()
+
+ # ====== Certificate Methods =======
+
+ @api.depends('l10n_sa_production_csid_json')
+ def _l10n_sa_compute_production_csid_validity(self):
+ """
+ Compute the expiration date of the Production certificate
+ """
+ for journal in self:
+ journal.l10n_sa_production_csid_validity = False
+ if journal.l10n_sa_production_csid_json:
+ journal.l10n_sa_production_csid_validity = self._l10n_sa_get_pcsid_validity(
+ json.loads(journal.l10n_sa_production_csid_json))
+
+ def _l10n_sa_reset_certificates(self):
+ """
+ Reset all certificate values, including CSR and compliance checks
+ """
+ for journal in self.sudo():
+ journal.l10n_sa_csr = False
+ journal.l10n_sa_production_csid_json = False
+ journal.l10n_sa_compliance_csid_json = False
+ journal.l10n_sa_compliance_checks_passed = False
+
+ def _l10n_sa_api_onboard_journal(self, otp):
+ """
+ Perform the onboarding for the journal. The onboarding consists of three steps:
+ 1. Get the Compliance CSID
+ 2. Perform the Compliance Checks
+ 3. Get the Production CSID
+ """
+ self.ensure_one()
+ try:
+ # If the company does not have a private key, we generate it.
+ # The private key is used to generate the CSR but also to sign the invoices
+ if not self.company_id.l10n_sa_private_key:
+ self.company_id.l10n_sa_private_key = self.company_id._l10n_sa_generate_private_key()
+ self._l10n_sa_generate_csr()
+ # STEP 1: The first step of the process is to get the CCSID
+ self._l10n_sa_get_compliance_CSID(otp)
+ # STEP 2: Once we have the CCSID, we preform the compliance checks
+ self._l10n_sa_run_compliance_checks()
+ # STEP 3: Once the compliance checks are completed, we request the PCSID
+ self._l10n_sa_get_production_CSID()
+ # Once all three steps are completed, we set the errors field to False
+ self.l10n_sa_csr_errors = False
+ except (RequestException, HTTPError, UserError) as e:
+ # In case of an exception returned from ZATCA (not timeout), we will need to regenerate the CSR
+ # As the same CSR cannot be used twice for the same CCSID request
+ self._l10n_sa_reset_certificates()
+ self.l10n_sa_csr_errors = e.args[0] or _("Journal could not be onboarded")
+
+ def _l10n_sa_get_compliance_CSID(self, otp):
+ """
+ Request a Compliance Cryptographic Stamp Identifier (CCSID) from ZATCA
+ """
+ CCSID_data = self._l10n_sa_api_get_compliance_CSID(otp)
+ if CCSID_data.get('error'):
+ raise UserError(_("Could not obtain Compliance CSID: %s") % CCSID_data['error'])
+ self.sudo().write({
+ 'l10n_sa_compliance_csid_json': json.dumps(CCSID_data),
+ 'l10n_sa_production_csid_json': False,
+ 'l10n_sa_compliance_checks_passed': False,
+ })
+
+ def _l10n_sa_get_production_CSID(self, OTP=None):
+ """
+ Request a Production Cryptographic Stamp Identifier (PCSID) from ZATCA
+ """
+
+ self_sudo = self.sudo()
+
+ if not self_sudo.l10n_sa_compliance_csid_json:
+ raise UserError(_("Cannot request a Production CSID before requesting a CCSID first"))
+ elif not self_sudo.l10n_sa_compliance_checks_passed:
+ raise UserError(_("Cannot request a Production CSID before completing the Compliance Checks"))
+
+ renew = False
+ zatca_format = self.env.ref('l10n_sa_edi.edi_sa_zatca')
+
+ if self_sudo.l10n_sa_production_csid_json:
+ time_now = zatca_format._l10n_sa_get_zatca_datetime(datetime.now())
+ if zatca_format._l10n_sa_get_zatca_datetime(self_sudo.l10n_sa_production_csid_validity) < time_now:
+ renew = True
+ else:
+ raise UserError(_("The Production CSID is still valid. You can only renew it once it has expired."))
+
+ CCSID_data = json.loads(self_sudo.l10n_sa_compliance_csid_json)
+ PCSID_data = self_sudo._l10n_sa_request_production_csid(CCSID_data, renew, OTP)
+ if PCSID_data.get('error'):
+ raise UserError(_("Could not obtain Production CSID: %s") % PCSID_data['error'])
+ self_sudo.l10n_sa_production_csid_json = json.dumps(PCSID_data)
+
+ # ====== Compliance Checks =======
+
+ def _l10n_sa_get_compliance_files(self):
+ """
+ Return the list of files to be used for the compliance checks.
+ """
+ file_names, compliance_files = [
+ 'standard/invoice.xml', 'standard/credit.xml', 'standard/debit.xml',
+ 'simplified/invoice.xml', 'simplified/credit.xml', 'simplified/debit.xml',
+ ], {}
+ for file in file_names:
+ fpath = get_module_resource('l10n_sa_edi', 'tests/compliance', file)
+ with open(fpath, 'rb') as ip:
+ compliance_files[file] = ip.read().decode()
+ return compliance_files
+
+ def _l10n_sa_run_compliance_checks(self):
+ """
+ Run Compliance Checks once the CCSID has been obtained.
+
+ The goal of the Compliance Checks is to make sure our system is able to produce, sign and send Invoices
+ correctly. For this we use dummy invoice UBL files available under the tests/compliance folder:
+
+ Standard Invoice, Standard Credit Note, Standard Debit Note, Simplified Invoice, Simplified Credit Note,
+ Simplified Debit Note.
+
+ We read each one of these files separately, sign them, then process them through the Compliance Checks API.
+ """
+
+ self.ensure_one()
+ self_sudo = self.sudo()
+ if self.country_code != 'SA':
+ raise UserError(_("Compliance checks can only be run for companies operating from KSA"))
+ if not self_sudo.l10n_sa_compliance_csid_json:
+ raise UserError(_("You need to request the CCSID first before you can proceed"))
+ CCSID_data = json.loads(self_sudo.l10n_sa_compliance_csid_json)
+ compliance_files = self._l10n_sa_get_compliance_files()
+ for fname, fval in compliance_files.items():
+ invoice_hash_hex = self.env['account.edi.xml.ubl_21.zatca']._l10n_sa_generate_invoice_xml_hash(
+ fval).decode()
+ digital_signature = self.env.ref('l10n_sa_edi.edi_sa_zatca')._l10n_sa_get_digital_signature(self.company_id, invoice_hash_hex).decode()
+ prepared_xml = self._l10n_sa_prepare_compliance_xml(fname, fval, CCSID_data['binarySecurityToken'],
+ digital_signature)
+ result = self._l10n_sa_api_compliance_checks(prepared_xml.decode(), CCSID_data)
+ if result.get('error'):
+ raise UserError(Markup("
%s %s
") % (_("Could not complete Compliance Checks for the following file:"), fname))
+ if result['validationResults']['status'] == 'WARNING':
+ warnings = "".join(Markup("
%s: %s
") % (e['code'], e['message']) for e in result['validationResults']['warningMessages'])
+ self.l10n_sa_csr_errors = Markup("
") % (e['code'], e['message']) for e in result['validationResults']['errorMessages'])
+ raise UserError(Markup("
%s %s %s
")
+ % (_("Could not complete Compliance Checks for the following file:"), fname, Markup("
%s%s
") % (_("Errors:"), errors)))
+ self.l10n_sa_compliance_checks_passed = True
+
+ def _l10n_sa_prepare_compliance_xml(self, xml_name, xml_raw, PCSID, signature):
+ """
+ Prepare XML content to be used for Compliance checks
+ """
+ xml_content = self._l10n_sa_prepare_invoice_xml(xml_raw)
+ signed_xml = self.env.ref('l10n_sa_edi.edi_sa_zatca')._l10n_sa_sign_xml(xml_content, PCSID, signature)
+ if xml_name.startswith('simplified'):
+ qr_code_str = self.env['account.move']._l10n_sa_get_qr_code(self, signed_xml, b64decode(PCSID).decode(),
+ signature, True)
+ root = etree.fromstring(signed_xml)
+ qr_node = root.xpath('//*[local-name()="ID"][text()="QR"]/following-sibling::*/*')[0]
+ qr_node.text = b64encode(qr_code_str).decode()
+ return etree.tostring(root, with_tail=False)
+ return signed_xml
+
+ def _l10n_sa_prepare_invoice_xml(self, xml_content):
+ """
+ Prepare the XML content of the test invoices before running the compliance checks
+ """
+ ubl_extensions = etree.fromstring(self.env['ir.qweb']._render('l10n_sa_edi.export_sa_zatca_ubl_extensions'))
+ root = etree.fromstring(xml_content.encode())
+ root.insert(0, ubl_extensions)
+ ns_map = self.env['account.edi.xml.ubl_21.zatca']._l10n_sa_get_namespaces()
+
+ def _get_node(xpath_str):
+ return root.xpath(xpath_str, namespaces=ns_map)[0]
+
+ # Update the Company VAT number in the test invoice
+ vat_el = _get_node('//cbc:CompanyID')
+ vat_el.text = self.company_id.vat
+
+ # Update the Company Name in the test invoice
+ name_nodes = ['cac:PartyName/cbc:Name', 'cac:PartyLegalEntity/cbc:RegistrationName', 'cac:Contact/cbc:Name']
+ for node in name_nodes:
+ comp_name_el = _get_node('//cac:AccountingSupplierParty/cac:Party/' + node)
+ comp_name_el.text = self.company_id.display_name
+
+ return etree.tostring(root)
+
+ # ====== Index Chain & Previous Invoice Calculation =======
+
+ def _l10n_sa_edi_get_next_chain_index(self):
+ self.ensure_one()
+ if not self.l10n_sa_chain_sequence_id:
+ self.l10n_sa_chain_sequence_id = self.env['ir.sequence'].create({
+ 'name': f'ZATCA account move sequence for Journal {self.name} (id: {self.id})',
+ 'code': f'l10n_sa_edi.account.move.{self.id}',
+ 'implementation': 'no_gap',
+ 'company_id': self.company_id.id,
+ })
+ return self.l10n_sa_chain_sequence_id.next_by_id()
+
+ def _l10n_sa_get_last_posted_invoice(self):
+ """
+ Returns the last invoice posted to this journal's chain.
+ That invoice may have been received by the govt or not (eg. in case of a timeout).
+ Only upon confirmed reception/refusal of that invoice can another one be posted.
+ """
+ self.ensure_one()
+ return self.env['account.move'].search(
+ [
+ ('journal_id', '=', self.id),
+ ('l10n_sa_chain_index', '!=', 0)
+ ],
+ limit=1, order='l10n_sa_chain_index desc'
+ )
+
+ # ====== API Calls to ZATCA =======
+
+ def _l10n_sa_api_get_compliance_CSID(self, otp):
+ """
+ API call to the Compliance CSID API to generate a CCSID certificate, password and compliance request_id
+ Requires a CSR token and a One Time Password (OTP)
+ """
+ self.ensure_one()
+ if not otp:
+ raise UserError(_("Please, set a valid OTP to be used for Onboarding"))
+ if not self.l10n_sa_csr:
+ raise UserError(_("Please, generate a CSR before requesting a CCSID"))
+ request_data = {
+ 'body': json.dumps({'csr': self.l10n_sa_csr.decode()}),
+ 'header': {'OTP': otp}
+ }
+ return self._l10n_sa_call_api(request_data, ZATCA_API_URLS['apis']['ccsid'], 'POST')
+
+ def _l10n_sa_api_get_production_CSID(self, CCSID_data):
+ """
+ API call to the Production CSID API to generate a PCSID certificate, password and production request_id
+ Requires a requestID from the Compliance CSID API
+ """
+ request_data = {
+ 'body': json.dumps({'compliance_request_id': str(CCSID_data['requestID'])}),
+ 'header': {'Authorization': self._l10n_sa_authorization_header(CCSID_data)}
+ }
+ return self._l10n_sa_call_api(request_data, ZATCA_API_URLS['apis']['pcsid'], 'POST')
+
+ def _l10n_sa_api_renew_production_CSID(self, PCSID_data, OTP):
+ """
+ API call to the Production CSID API to renew a PCSID certificate, password and production request_id
+ Requires an expired Production CSIDPCSID_data
+ """
+ self.ensure_one()
+ auth_data = PCSID_data
+ # For renewal, the sandbox API expects a specific Username/Password, which are set in the SANDBOX_AUTH dict
+ if self.company_id.l10n_sa_api_mode == 'sandbox':
+ auth_data = SANDBOX_AUTH
+ request_data = {
+ 'body': json.dumps({'csr': self.l10n_sa_csr.decode()}),
+ 'header': {
+ 'OTP': OTP,
+ 'Authorization': self._l10n_sa_authorization_header(auth_data)
+ }
+ }
+ return self._l10n_sa_call_api(request_data, ZATCA_API_URLS['apis']['pcsid'], 'PATCH')
+
+ def _l10n_sa_api_compliance_checks(self, xml_content, CCSID_data):
+ """
+ API call to the COMPLIANCE endpoint to generate a security token used for subsequent API calls
+ Requires a CSR token and a One Time Password (OTP)
+ """
+ invoice_tree = etree.fromstring(xml_content)
+
+ # Get the Invoice Hash from the XML document
+ invoice_hash_node = invoice_tree.xpath('//*[@Id="invoiceSignedData"]/*[local-name()="DigestValue"]')[0]
+ invoice_hash = invoice_hash_node.text
+
+ # Get the Invoice UUID from the XML document
+ invoice_uuid_node = invoice_tree.xpath('//*[local-name()="UUID"]')[0]
+ invoice_uuid = invoice_uuid_node.text
+
+ request_data = {
+ 'body': json.dumps({
+ "invoiceHash": invoice_hash,
+ "uuid": invoice_uuid,
+ "invoice": b64encode(xml_content.encode()).decode()
+ }),
+ 'header': {
+ 'Authorization': self._l10n_sa_authorization_header(CCSID_data),
+ 'Clearance-Status': '1'
+ }
+ }
+ return self._l10n_sa_call_api(request_data, ZATCA_API_URLS['apis']['compliance'], 'POST')
+
+ def _l10n_sa_get_api_clearance_url(self, invoice):
+ """
+ Return the API to be used for clearance. To be overridden to account for other cases, such as reporting.
+ """
+ return ZATCA_API_URLS['apis']['reporting' if invoice._l10n_sa_is_simplified() else 'clearance']
+
+ def _l10n_sa_api_clearance(self, invoice, xml_content, PCSID_data):
+ """
+ API call to the CLEARANCE/REPORTING endpoint to sign an invoice
+ - If SIMPLIFIED invoice: Reporting
+ - If STANDARD invoice: Clearance
+ """
+ invoice_tree = etree.fromstring(xml_content)
+ invoice_hash_node = invoice_tree.xpath('//*[@Id="invoiceSignedData"]/*[local-name()="DigestValue"]')[0]
+ invoice_hash = invoice_hash_node.text
+ request_data = {
+ 'body': json.dumps({
+ "invoiceHash": invoice_hash,
+ "uuid": invoice.l10n_sa_uuid,
+ "invoice": b64encode(xml_content.encode()).decode()
+ }),
+ 'header': {
+ 'Authorization': self._l10n_sa_authorization_header(PCSID_data),
+ 'Clearance-Status': '1'
+ }
+ }
+ url_string = self._l10n_sa_get_api_clearance_url(invoice)
+ return self._l10n_sa_call_api(request_data, url_string, 'POST')
+
+ # ====== Certificate Methods =======
+
+ def _l10n_sa_get_pcsid_validity(self, PCSID_data):
+ """
+ Return PCSID expiry date
+ """
+ b64_decoded_pcsid = b64decode(PCSID_data['binarySecurityToken'])
+ x509_certificate = load_der_x509_certificate(b64decode(b64_decoded_pcsid.decode()), default_backend())
+ return x509_certificate.not_valid_after
+
+ def _l10n_sa_request_production_csid(self, csid_data, renew=False, otp=None):
+ """
+ Generate company Production CSID data
+ """
+ self.ensure_one()
+ return (
+ self._l10n_sa_api_renew_production_CSID(csid_data, otp)
+ if renew
+ else self._l10n_sa_api_get_production_CSID(csid_data)
+ )
+
+ def _l10n_sa_api_get_pcsid(self):
+ """
+ Get CSIDs required to perform ZATCA api calls, and regenerate them if they need to be regenerated.
+ """
+ self.ensure_one()
+ if not self.l10n_sa_production_csid_json:
+ raise UserError(_("Please, make a request to obtain the Compliance CSID and Production CSID before sending "
+ "documents to ZATCA"))
+ pcsid_validity = self.env.ref('l10n_sa_edi.edi_sa_zatca')._l10n_sa_get_zatca_datetime(self.l10n_sa_production_csid_validity)
+ time_now = self.env.ref('l10n_sa_edi.edi_sa_zatca')._l10n_sa_get_zatca_datetime(datetime.now())
+ if pcsid_validity < time_now and self.company_id.l10n_sa_api_mode != 'sandbox':
+ raise UserError(_("Production certificate has expired, please renew the PCSID before proceeding"))
+ return json.loads(self.l10n_sa_production_csid_json)
+
+ # ====== API Helper Methods =======
+
+ def _l10n_sa_call_api(self, request_data, request_url, method):
+ """
+ Helper function to make api calls to the ZATCA API Endpoint
+ """
+ api_url = ZATCA_API_URLS[self.env.company.l10n_sa_api_mode]
+ request_url = urljoin(api_url, request_url)
+ try:
+ request_response = requests.request(method, request_url, data=request_data.get('body'),
+ headers={
+ **self._l10n_sa_api_headers(),
+ **request_data.get('header')
+ }, timeout=(30, 30))
+ request_response.raise_for_status()
+ except (ValueError, HTTPError) as ex:
+ # In the case of an explicit error from ZATCA, i.e we got a response but the code of the response is not 2xx
+ return {
+ 'error': _("Server returned an unexpected error: ") + (request_response.text or str(ex)),
+ 'blocking_level': 'error'
+ }
+ except RequestException as ex:
+ # Usually only happens if a Timeout occurs. In this case we're not sure if the invoice was accepted or
+ # rejected, or if it even made it to ZATCA
+ return {'error': str(ex), 'blocking_level': 'warning', 'excepted': True}
+
+ try:
+ response_data = request_response.json()
+ except json.decoder.JSONDecodeError:
+ return {
+ 'error': _("JSON response from ZATCA could not be decoded"),
+ 'blocking_level': 'error'
+ }
+
+ if not request_response.ok and (response_data.get('errors') or response_data.get('warnings')):
+ if isinstance(response_data, dict) and response_data.get('errors'):
+ return {
+ 'error': _("Invoice submission to ZATCA returned errors"),
+ 'json_errors': response_data['errors'],
+ 'blocking_level': 'error',
+ }
+ return {
+ 'error': request_response.reason,
+ 'blocking_level': 'error'
+ }
+ return response_data
+
+ def _l10n_sa_api_headers(self):
+ """
+ Return the base headers to be included in ZATCA API calls
+ """
+ return {
+ 'Content-Type': 'application/json',
+ 'Accept-Language': 'en',
+ 'Accept-Version': 'V2'
+ }
+
+ def _l10n_sa_authorization_header(self, CSID_data):
+ """
+ Compute the Authorization header by combining the CSID and the Secret key, then encode to Base64
+ """
+ auth_data = CSID_data
+ auth_str = "%s:%s" % (auth_data['binarySecurityToken'], auth_data['secret'])
+ return 'Basic ' + b64encode(auth_str.encode()).decode()
+
+ def _l10n_sa_load_edi_demo_data(self):
+ self.ensure_one()
+ self.company_id.l10n_sa_private_key = self.company_id._l10n_sa_generate_private_key()
+ self.write({
+ 'l10n_sa_serial_number': 'SIDI3-CBMPR-L2D8X-KM0KN-X4ISJ',
+ 'l10n_sa_compliance_checks_passed': True,
+ 'l10n_sa_csr': b'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURSBSRVFVRVNULS0tLS0KTUlJQ2NqQ0NBaGNDQVFBd2djRXhDekFKQmdOVkJBWVRBbE5CTVJNd0VRWURWUVFMREFvek1UQXhOelV6T1RjMApNUk13RVFZRFZRUUtEQXBUUVNCRGIyMXdZVzU1TVJNd0VRWURWUVFEREFwVFFTQkRiMjF3WVc1NU1SZ3dGZ1lEClZRUmhEQTh6TVRBeE56VXpPVGMwTURBd01ETXhEekFOQmdOVkJBZ01CbEpwZVdGa2FERklNRVlHQTFVRUJ3dy8KdzVqQ3A4T1o0b0NldzVuaWdLYkRtTUt2dzVuRm9NT1o0b0NndzVqQ3FTRERtTUtudzVuaWdKN0RtZUtBcHNPWgo0b0NndzVuTGhzT1l3ckhEbU1LcE1GWXdFQVlIS29aSXpqMENBUVlGSzRFRUFBb0RRZ0FFN2ZpZWZWQ21HcTlzCmV0OVl4aWdQNzZWUmJxZlh0VWNtTk1VN3FkTlBiSm5NNGh5R1QwanpPcXUrSWNXWW5IelFJYmxJVmsydENPQnQKYjExanY4MGVwcUNCOVRDQjhnWUpLb1pJaHZjTkFRa09NWUhrTUlIaE1DUUdDU3NHQVFRQmdqY1VBZ1FYRXhWUQpVa1ZhUVZSRFFTMURiMlJsTFZOcFoyNXBibWN3Z2JnR0ExVWRFUVNCc0RDQnJhU0JxakNCcHpFME1ESUdBMVVFCkJBd3JNUzFQWkc5dmZESXRNVFY4TXkxVFNVUkpNeTFEUWsxUVVpMU1Na1E0V0MxTFRUQkxUaTFZTkVsVFNqRWYKTUIwR0NnbVNKb21UOGl4a0FRRU1Eek14TURFM05UTTVOelF3TURBd016RU5NQXNHQTFVRURBd0VNVEV3TURFdgpNQzBHQTFVRUdnd21RV3dnUVcxcGNpQk5iMmhoYlcxbFpDQkNhVzRnUVdKa2RXd2dRWHBwZWlCVGRISmxaWFF4CkRqQU1CZ05WQkE4TUJVOTBhR1Z5TUFvR0NDcUdTTTQ5QkFNQ0Ewa0FNRVlDSVFEb3VCeXhZRDRuQ2pUQ2V6TkYKczV6SmlVWW1QZVBRNnFWNDdZemRHeWRla1FJaEFPRjNVTWF4UFZuc29zOTRFMlNkT2JJcTVYYVAvKzlFYWs5TgozMUtWRUkvTQotLS0tLUVORCBDRVJUSUZJQ0FURSBSRVFVRVNULS0tLS0K',
+ 'l10n_sa_compliance_csid_json': """{"requestID": 1234567890123, "dispositionMessage": "ISSUED", "binarySecurityToken": "TUlJQ2xUQ0NBanVnQXdJQkFnSUdBWWgydEhlOU1Bb0dDQ3FHU000OUJBTUNNQlV4RXpBUkJnTlZCQU1NQ21WSmJuWnZhV05wYm1jd0hoY05Nak13TmpBeE1URXlOVEV6V2hjTk1qZ3dOVE14TWpFd01EQXdXakNCd1RFTE1Ba0dBMVVFQmhNQ1UwRXhFekFSQmdOVkJBc01Dak14TURFM05UTTVOelF4RXpBUkJnTlZCQW9NQ2xOQklFTnZiWEJoYm5reEV6QVJCZ05WQkFNTUNsTkJJRU52YlhCaGJua3hHREFXQmdOVkJHRU1Eek14TURFM05UTTVOelF3TURBd016RVBNQTBHQTFVRUNBd0dVbWw1WVdSb01VZ3dSZ1lEVlFRSEREL0RtTUtudzVuaWdKN0RtZUtBcHNPWXdxL0RtY1dndzVuaWdLRERtTUtwSU1PWXdxZkRtZUtBbnNPWjRvQ213NW5pZ0tERG1jdUd3NWpDc2NPWXdxa3dWakFRQmdjcWhrak9QUUlCQmdVcmdRUUFDZ05DQUFUdCtKNTlVS1lhcjJ4NjMxakdLQS92cFZGdXA5ZTFSeVkweFR1cDAwOXNtY3ppSElaUFNQTTZxNzRoeFppY2ZOQWh1VWhXVGEwSTRHMXZYV08velI2bW80SE1NSUhKTUF3R0ExVWRFd0VCL3dRQ01BQXdnYmdHQTFVZEVRU0JzRENCcmFTQnFqQ0JwekUwTURJR0ExVUVCQXdyTVMxUFpHOXZmREl0TVRWOE15MVRTVVJKTXkxRFFrMVFVaTFNTWtRNFdDMUxUVEJMVGkxWU5FbFRTakVmTUIwR0NnbVNKb21UOGl4a0FRRU1Eek14TURFM05UTTVOelF3TURBd016RU5NQXNHQTFVRURBd0VNVEV3TURFdk1DMEdBMVVFR2d3bVFXd2dRVzFwY2lCTmIyaGhiVzFsWkNCQ2FXNGdRV0prZFd3Z1FYcHBlaUJUZEhKbFpYUXhEakFNQmdOVkJBOE1CVTkwYUdWeU1Bb0dDQ3FHU000OUJBTUNBMGdBTUVVQ0lRQ2FBNlNKMXBXWDQ4UUE1V1pZVEQ4VmJpODFwZExSY01iZm1NQStZMmNBWlFJZ0NqbXp6Uzh4TnNDWllvWTFoWGIrN3R2NUpKRDVWeUVMR3hER1lyRHFpa2c9", "secret": "dBwSQ1ykNStUO6XRQAQhuDAWAdg/GgNZYNmiwClAGcQ=", "errors": null}""",
+ 'l10n_sa_production_csid_json': """{"requestID": 30368, "tokenType": "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3", "dispositionMessage": "ISSUED", "binarySecurityToken": "TUlJRDJ6Q0NBNENnQXdJQkFnSVRid0FBZHFEbUlocXNqcG01Q3dBQkFBQjJvREFLQmdncWhrak9QUVFEQWpCak1SVXdFd1lLQ1pJbWlaUHlMR1FCR1JZRmJHOWpZV3d4RXpBUkJnb0praWFKay9Jc1pBRVpGZ05uYjNZeEZ6QVZCZ29Ka2lhSmsvSXNaQUVaRmdkbGVIUm5ZWHAwTVJ3d0dnWURWUVFERXhOVVUxcEZTVTVXVDBsRFJTMVRkV0pEUVMweE1CNFhEVEl5TURNeU9ERTFORFl6TWxvWERUSXlNRE16TURFMU5EWXpNbG93VFRFTE1Ba0dBMVVFQmhNQ1UwRXhEakFNQmdOVkJBb1RCVXBoY21seU1Sb3dHQVlEVlFRTEV4RktaV1JrWVdnZ1FuSmhibU5vTVRJek5ERVNNQkFHQTFVRUF4TUpNVEkzTGpBdU1DNHhNRll3RUFZSEtvWkl6ajBDQVFZRks0RUVBQW9EUWdBRUQvd2IybGhCdkJJQzhDbm5adm91bzZPelJ5bXltVTlOV1JoSXlhTWhHUkVCQ0VaQjRFQVZyQnVWMnhYaXhZNHFCWWY5ZGRlcnprVzlEd2RvM0lsSGdxT0NBaW93Z2dJbU1JR0xCZ05WSFJFRWdZTXdnWUNrZmpCOE1Sd3dHZ1lEVlFRRURCTXlNakl5TWpNeU5EUTBNelF6YW1abU5ETXlNUjh3SFFZS0NaSW1pWlB5TEdRQkFRd1BNekV3TVRjMU16azNOREF3TURBek1RMHdDd1lEVlFRTURBUXhNREV4TVJFd0R3WURWUVFhREFoVFlXMXdiR1VnUlRFWk1CY0dBMVVFRHd3UVUyRnRjR3hsSUVKMWMzTnBibVZ6Y3pBZEJnTlZIUTRFRmdRVWhXY3NiYkpoakQ1WldPa3dCSUxDK3dOVmZLWXdId1lEVlIwakJCZ3dGb0FVZG1DTSt3YWdyR2RYTlozUG1xeW5LNWsxdFM4d1RnWURWUjBmQkVjd1JUQkRvRUdnUDRZOWFIUjBjRG92TDNSemRHTnliQzU2WVhSallTNW5iM1l1YzJFdlEyVnlkRVZ1Y205c2JDOVVVMXBGU1U1V1QwbERSUzFUZFdKRFFTMHhMbU55YkRDQnJRWUlLd1lCQlFVSEFRRUVnYUF3Z1owd2JnWUlLd1lCQlFVSE1BR0dZbWgwZEhBNkx5OTBjM1JqY213dWVtRjBZMkV1WjI5MkxuTmhMME5sY25SRmJuSnZiR3d2VkZOYVJXbHVkbTlwWTJWVFEwRXhMbVY0ZEdkaGVuUXVaMjkyTG14dlkyRnNYMVJUV2tWSlRsWlBTVU5GTFZOMVlrTkJMVEVvTVNrdVkzSjBNQ3NHQ0NzR0FRVUZCekFCaGg5b2RIUndPaTh2ZEhOMFkzSnNMbnBoZEdOaExtZHZkaTV6WVM5dlkzTndNQTRHQTFVZER3RUIvd1FFQXdJSGdEQWRCZ05WSFNVRUZqQVVCZ2dyQmdFRkJRY0RBZ1lJS3dZQkJRVUhBd013SndZSkt3WUJCQUdDTnhVS0JCb3dHREFLQmdnckJnRUZCUWNEQWpBS0JnZ3JCZ0VGQlFjREF6QUtCZ2dxaGtqT1BRUURBZ05KQURCR0FpRUF5Tmh5Y1EzYk5sTEZkT1BscVlUNlJWUVRXZ25LMUdoME5IZGNTWTRQZkMwQ0lRQ1NBdGhYdnY3dGV0VUw2OVdqcDhCeG5MTE13ZXJ4WmhCbmV3by9nRjNFSkE9PQ==", "secret": "f9YRhopN/G7x0TECOY6nKSCHLNYlb5riAHSFPICo4qw="}"""
+ })
diff --git a/addons/l10n_sa_edi/models/account_move.py b/addons/l10n_sa_edi/models/account_move.py
new file mode 100644
index 00000000000..45455adf8df
--- /dev/null
+++ b/addons/l10n_sa_edi/models/account_move.py
@@ -0,0 +1,204 @@
+import uuid
+import json
+from markupsafe import Markup
+from odoo import _, fields, models, api
+from odoo.tools import float_repr
+from datetime import datetime
+from base64 import b64decode, b64encode
+from lxml import etree
+from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
+from cryptography.hazmat.backends import default_backend
+from cryptography.x509 import load_der_x509_certificate
+
+
+class AccountMove(models.Model):
+ _inherit = 'account.move'
+
+ l10n_sa_uuid = fields.Char(string='Document UUID (SA)', copy=False, help="Universally unique identifier of the Invoice")
+
+ l10n_sa_invoice_signature = fields.Char("Unsigned XML Signature", copy=False)
+
+ l10n_sa_chain_index = fields.Integer(
+ string="ZATCA chain index", copy=False, readonly=True,
+ help="Invoice index in chain, set if and only if an in-chain XML was submitted and did not error",
+ )
+
+ def _l10n_sa_is_simplified(self):
+ """
+ Returns True if the customer is an individual, i.e: The invoice is B2C
+ :return:
+ """
+ self.ensure_one()
+ return self.partner_id.company_type == 'person'
+
+ @api.depends('amount_total_signed', 'amount_tax_signed', 'l10n_sa_confirmation_datetime', 'company_id',
+ 'company_id.vat', 'journal_id', 'journal_id.l10n_sa_production_csid_json',
+ 'l10n_sa_invoice_signature', 'l10n_sa_chain_index')
+ def _compute_qr_code_str(self):
+ """ Override to update QR code generation in accordance with ZATCA Phase 2"""
+ for move in self:
+ move.l10n_sa_qr_code_str = ''
+ if move.country_code == 'SA' and move.move_type in ('out_invoice', 'out_refund') and move.l10n_sa_chain_index:
+ edi_format = self.env.ref('l10n_sa_edi.edi_sa_zatca')
+ zatca_document = move.edi_document_ids.filtered(lambda d: d.edi_format_id == edi_format)
+ if move._l10n_sa_is_simplified():
+ x509_cert = json.loads(move.journal_id.l10n_sa_production_csid_json)['binarySecurityToken']
+ xml_content = self.env.ref('l10n_sa_edi.edi_sa_zatca')._l10n_sa_generate_zatca_template(move)
+ qr_code_str = move._l10n_sa_get_qr_code(move.journal_id, xml_content, b64decode(x509_cert), move.l10n_sa_invoice_signature, move._l10n_sa_is_simplified())
+ move.l10n_sa_qr_code_str = b64encode(qr_code_str).decode()
+ elif zatca_document.state == 'sent' and zatca_document.attachment_id.datas:
+ document_xml = zatca_document.attachment_id.datas.decode()
+ root = etree.fromstring(b64decode(document_xml))
+ qr_node = root.xpath('//*[local-name()="ID"][text()="QR"]/following-sibling::*/*')[0]
+ move.l10n_sa_qr_code_str = qr_node.text
+
+
+ def _l10n_sa_get_qr_code_encoding(self, tag, field, int_length=1):
+ """
+ Helper function to encode strings for the QR code generation according to ZATCA specs
+ """
+ company_name_tag_encoding = tag.to_bytes(length=1, byteorder='big')
+ company_name_length_encoding = len(field).to_bytes(length=int_length, byteorder='big')
+ return company_name_tag_encoding + company_name_length_encoding + field
+
+ def _l10n_sa_check_refund_reason(self):
+ """
+ Make sure credit/debit notes have a valid reason and reversal reference
+ """
+ self.ensure_one()
+ return self.reversed_entry_id and self.ref
+
+ @api.model
+ def _l10n_sa_get_qr_code(self, journal_id, unsigned_xml, x509_cert, signature, is_b2c=False):
+ """
+ Generate QR code string based on XML content of the Invoice UBL file, X509 Production Certificate
+ and company info.
+
+ :return b64 encoded QR code string
+ """
+
+ def xpath_ns(expr):
+ return root.xpath(expr, namespaces=edi_format._l10n_sa_get_namespaces())[0].text.strip()
+
+ qr_code_str = ''
+ root = etree.fromstring(unsigned_xml)
+ edi_format = self.env['account.edi.xml.ubl_21.zatca']
+
+ # Indent XML content to avoid indentation mismatches
+ etree.indent(root, space=' ')
+
+ invoice_date = xpath_ns('//cbc:IssueDate')
+ invoice_time = xpath_ns('//cbc:IssueTime')
+ invoice_datetime = datetime.strptime(invoice_date + ' ' + invoice_time, '%Y-%m-%d %H:%M:%S')
+
+ if invoice_datetime and journal_id.company_id.vat and x509_cert:
+ prehash_content = etree.tostring(root)
+ invoice_hash = edi_format._l10n_sa_generate_invoice_xml_hash(prehash_content, 'digest')
+
+ amount_total = float(xpath_ns('//cbc:TaxInclusiveAmount'))
+ amount_tax = float(xpath_ns('//cac:TaxTotal/cbc:TaxAmount'))
+ x509_certificate = load_der_x509_certificate(b64decode(x509_cert), default_backend())
+ seller_name_enc = self._l10n_sa_get_qr_code_encoding(1, journal_id.company_id.display_name.encode())
+ seller_vat_enc = self._l10n_sa_get_qr_code_encoding(2, journal_id.company_id.vat.encode())
+ timestamp_enc = self._l10n_sa_get_qr_code_encoding(3,
+ invoice_datetime.strftime("%Y-%m-%dT%H:%M:%SZ").encode())
+ amount_total_enc = self._l10n_sa_get_qr_code_encoding(4, float_repr(abs(amount_total), 2).encode())
+ amount_tax_enc = self._l10n_sa_get_qr_code_encoding(5, float_repr(abs(amount_tax), 2).encode())
+ invoice_hash_enc = self._l10n_sa_get_qr_code_encoding(6, invoice_hash)
+ signature_enc = self._l10n_sa_get_qr_code_encoding(7, signature.encode())
+ public_key_enc = self._l10n_sa_get_qr_code_encoding(8,
+ x509_certificate.public_key().public_bytes(Encoding.DER,
+ PublicFormat.SubjectPublicKeyInfo))
+
+ qr_code_str = (seller_name_enc + seller_vat_enc + timestamp_enc + amount_total_enc +
+ amount_tax_enc + invoice_hash_enc + signature_enc + public_key_enc)
+
+ if is_b2c:
+ qr_code_str += self._l10n_sa_get_qr_code_encoding(9, x509_certificate.signature)
+
+ return qr_code_str
+
+ @api.depends('state', 'edi_document_ids.state')
+ def _compute_edi_show_cancel_button(self):
+ """
+ Override to hide the EDI Cancellation button at all times for ZATCA Invoices
+ """
+ super()._compute_edi_show_cancel_button()
+ for move in self.filtered(lambda m: m.is_invoice() and m.country_code == 'SA'):
+ move.edi_show_cancel_button = False
+
+ @api.depends('state', 'edi_document_ids.state')
+ def _compute_show_reset_to_draft_button(self):
+ """
+ Override to hide the Reset to Draft button for ZATCA Invoices that have been successfully submitted
+ """
+ super()._compute_show_reset_to_draft_button()
+ for move in self:
+ # An invoice should only have an index chain if it was successfully submitted without rejection,
+ # or if the submission timed out. In both cases, a user should not be able to reset it to draft.
+ if move.l10n_sa_chain_index:
+ move.show_reset_to_draft_button = False
+
+ def _l10n_sa_generate_unsigned_data(self):
+ """
+ Generate UUID and digital signature to be used during both Signing and QR code generation.
+ It is necessary to save the signature as it changes everytime it is generated and both the signing and the
+ QR code expect to have the same, identical signature.
+ """
+ self.ensure_one()
+ edi_format = self.env.ref('l10n_sa_edi.edi_sa_zatca')
+ # Build the dict of values to be used for generating the Invoice XML content
+ # Set Invoice field values required for generating the XML content, hash and signature
+ self.l10n_sa_uuid = uuid.uuid4()
+ # We generate the XML content
+ xml_content = edi_format._l10n_sa_generate_zatca_template(self)
+ # Once the required values are generated, we hash the invoice, then use it to generate a Signature
+ invoice_hash_hex = self.env['account.edi.xml.ubl_21.zatca']._l10n_sa_generate_invoice_xml_hash(xml_content).decode()
+ self.l10n_sa_invoice_signature = edi_format._l10n_sa_get_digital_signature(self.journal_id.company_id,
+ invoice_hash_hex).decode()
+ return xml_content
+
+ def _l10n_sa_log_results(self, xml_content, response_data=None, error=False):
+ """
+ Save submitted invoice XML hash in case of either Rejection or Acceptance.
+ """
+ self.ensure_one()
+ self.journal_id.l10n_sa_latest_submission_hash = self.env['account.edi.xml.ubl_21.zatca']._l10n_sa_generate_invoice_xml_hash(
+ xml_content)
+ bootstrap_cls, title, content = ("success", _("Invoice Successfully Submitted to ZATCA"),
+ "" if (not error or not response_data) else response_data)
+ if error:
+ bootstrap_cls, title = ("danger", _("Invoice was rejected by ZATCA"))
+ content = Markup("""
+
+ %s
+
+
+
+ %s
+
+ """) % (_('The invoice was rejected by ZATCA. Please, check the response below:'), response_data)
+ if response_data and response_data.get('validationResults', {}).get('warningMessages'):
+ bootstrap_cls, title = ("warning", _("Invoice was Accepted by ZATCA (with Warnings)"))
+ content = Markup("""
+
+ %s
+
+
+
+ %s
+
+ """) % (_('The invoice was accepted by ZATCA, but returned warnings. Please, check the response below:'), " ".join([Markup("%s : %s") % (m['code'], m['message']) for m in response_data['validationResults']['warningMessages']]))
+ self.message_post(body=Markup("""
+
+
%s
%s
+
+ """) % (bootstrap_cls, title, content))
+
+ def _l10n_sa_is_in_chain(self):
+ """
+ If the invoice was successfully posted and confirmed by the government, then this would return True.
+ If the invoice timed out, then its edi_document should still be in the 'to_send' state.
+ """
+ zatca_doc_ids = self.edi_document_ids.filtered(lambda d: d.edi_format_id.code == 'sa_zatca')
+ return len(zatca_doc_ids) > 0 and not any(zatca_doc_ids.filtered(lambda d: d.state == 'to_send'))
diff --git a/addons/l10n_sa_edi/models/account_tax.py b/addons/l10n_sa_edi/models/account_tax.py
new file mode 100644
index 00000000000..ad2baec2ac3
--- /dev/null
+++ b/addons/l10n_sa_edi/models/account_tax.py
@@ -0,0 +1,41 @@
+from odoo import fields, models, api, _
+from odoo.exceptions import UserError
+
+
+EXEMPTION_REASON_CODES = [
+ ('VATEX-SA-29', 'VATEX-SA-29 Financial services mentioned in Article 29 of the VAT Regulations.'),
+ ('VATEX-SA-29-7', 'VATEX-SA-29-7 Life insurance services mentioned in Article 29 of the VAT.'),
+ ('VATEX-SA-30', 'VATEX-SA-30 Real estate transactions mentioned in Article 30 of the VAT Regulations.'),
+ ('VATEX-SA-32', 'VATEX-SA-32 Export of goods.'),
+ ('VATEX-SA-33', 'VATEX-SA-33 Export of Services.'),
+ ('VATEX-SA-34-1', 'VATEX-SA-34-1 The international transport of Goods.'),
+ ('VATEX-SA-34-2', 'VATEX-SA-34-1 The international transport of Passengers.'),
+ ('VATEX-SA-34-3', 'VATEX-SA-34-3 Services directly connected and incidental to a Supply of international passenger transport.'),
+ ('VATEX-SA-34-4', 'VATEX-SA-34-4 Supply of a qualifying means of transport.'),
+ ('VATEX-SA-34-5', 'VATEX-SA-34-5 Any services relating to Goods or passenger transportation, as defined in article twenty five of these Regulations.'),
+ ('VATEX-SA-35', 'VATEX-SA-35 Medicines and medical equipment.'),
+ ('VATEX-SA-36', 'VATEX-SA-36 Qualifying metals.'),
+ ('VATEX-SA-EDU', 'VATEX-SA-EDU Private education to citizen.'),
+ ('VATEX-SA-HEA', 'VATEX-SA-HEA Private healthcare to citizen.')
+]
+
+
+class AccountTax(models.Model):
+ _inherit = 'account.tax'
+
+ l10n_sa_is_retention = fields.Boolean("Is Retention", default=False,
+ help="Determines whether or not a tax counts as a Withholding Tax")
+
+ l10n_sa_exemption_reason_code = fields.Selection(string="Exemption Reason Code",
+ selection=EXEMPTION_REASON_CODES, help="Tax Exemption Reason Code (ZATCA)")
+
+ @api.onchange('amount')
+ def onchange_amount(self):
+ super().onchange_amount()
+ self.l10n_sa_is_retention = False
+
+ @api.constrains("l10n_sa_is_retention", "amount", "type_tax_use")
+ def _l10n_sa_constrain_is_retention(self):
+ for tax in self:
+ if tax.amount >= 0 and tax.l10n_sa_is_retention and tax.type_tax_use == 'sale':
+ raise UserError(_("Cannot set a tax to Retention if the amount is greater than or equal 0"))
diff --git a/addons/l10n_sa_edi/models/res_company.py b/addons/l10n_sa_edi/models/res_company.py
new file mode 100644
index 00000000000..e5db7e0a752
--- /dev/null
+++ b/addons/l10n_sa_edi/models/res_company.py
@@ -0,0 +1,93 @@
+import re
+from odoo import models, fields
+from odoo.exceptions import UserError
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import ec
+
+
+class ResCompany(models.Model):
+ _inherit = "res.company"
+
+ def _l10n_sa_generate_private_key(self):
+ """
+ Compute a private key for each company that will be used to generate certificate signing requests (CSR)
+ in order to receive X509 certificates from the ZATCA APIs and sign EDI documents
+
+ - public_exponent=65537 is a default value that should be used most of the time, as per the documentation
+ of cryptography.
+ - key_size=2048 is considered a reasonable default key size, as per the documentation of cryptography.
+
+ See https://cryptography.io/en/latest/hazmat/primitives/asymmetric/ec/
+ """
+ private_key = ec.generate_private_key(ec.SECP256K1, default_backend())
+ return private_key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
+ encryption_algorithm=serialization.NoEncryption())
+
+ l10n_sa_private_key = fields.Binary("ZATCA Private key", attachment=False, groups="base.group_system", copy=False,
+ help="The private key used to generate the CSR and obtain certificates",)
+
+ l10n_sa_api_mode = fields.Selection(
+ [('sandbox', 'Sandbox'), ('preprod', 'Simulation (Pre-Production)'), ('prod', 'Production')],
+ help="Specifies which API the system should use", required=True,
+ default='sandbox', copy=False)
+
+ l10n_sa_edi_building_number = fields.Char(compute='_compute_address',
+ inverse='_l10n_sa_edi_inverse_building_number')
+ l10n_sa_edi_plot_identification = fields.Char(compute='_compute_address',
+ inverse='_l10n_sa_edi_inverse_plot_identification')
+
+ l10n_sa_additional_identification_scheme = fields.Selection(
+ related='partner_id.l10n_sa_additional_identification_scheme', readonly=False)
+ l10n_sa_additional_identification_number = fields.Char(
+ related='partner_id.l10n_sa_additional_identification_number', readonly=False)
+
+ def write(self, vals):
+ for company in self:
+ if 'l10n_sa_api_mode' in vals:
+ if company.l10n_sa_api_mode == 'prod' and vals['l10n_sa_api_mode'] != 'prod':
+ raise UserError("You cannot change the ZATCA Submission Mode once it has been set to Production")
+ journals = self.env['account.journal'].search([('company_id', '=', company.id)])
+ journals._l10n_sa_reset_certificates()
+ journals.l10n_sa_latest_submission_hash = False
+ return super().write(vals)
+
+ def _get_company_address_field_names(self):
+ """ Override to add ZATCA specific address fields """
+ return super()._get_company_address_field_names() + \
+ ['l10n_sa_edi_building_number', 'l10n_sa_edi_plot_identification']
+
+ def _l10n_sa_edi_inverse_building_number(self):
+ for company in self:
+ company.partner_id.l10n_sa_edi_building_number = company.l10n_sa_edi_building_number
+
+ def _l10n_sa_edi_inverse_plot_identification(self):
+ for company in self:
+ company.partner_id.l10n_sa_edi_plot_identification = company.l10n_sa_edi_plot_identification
+
+ def _l10n_sa_get_csr_invoice_type(self):
+ """
+ Return the Invoice Type flag used in the CSR. 4-digit numerical input using 0 & 1 mapped to “TSCZ” where:
+ - 0: False/Not supported, 1: True/Supported
+ - T: Tax Invoice (Standard), S: Simplified Invoice, C & Z will be used in the future and should
+ always be 0
+ For example: 1100 would mean the Solution will be generating Standard and Simplified invoices.
+ We can assume Odoo-powered EGS solutions will always generate both Standard & Simplified invoices
+ :return:
+ """
+ return '1100'
+
+ def _l10n_sa_check_organization_unit(self):
+ """
+ Check company Organization Unit according to ZATCA specifications
+ Standards:
+ BR-KSA-39
+ BR-KSA-40
+ See https://zatca.gov.sa/ar/RulesRegulations/Taxes/Documents/20210528_ZATCA_Electronic_Invoice_XML_Implementation_Standard_vShared.pdf
+ """
+ self.ensure_one()
+ if not self.vat:
+ return False
+ return len(self.vat) == 15 and bool(re.match(r'^3\d{13}3$', self.vat))
diff --git a/addons/l10n_sa_edi/models/res_config_settings.py b/addons/l10n_sa_edi/models/res_config_settings.py
new file mode 100644
index 00000000000..64ed9e43959
--- /dev/null
+++ b/addons/l10n_sa_edi/models/res_config_settings.py
@@ -0,0 +1,14 @@
+from odoo import models, fields, api, _
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ l10n_sa_api_mode = fields.Selection(related='company_id.l10n_sa_api_mode', readonly=False)
+
+ @api.depends('company_id')
+ def _compute_company_informations(self):
+ super()._compute_company_informations()
+ for record in self:
+ if self.company_id.country_code == 'SA':
+ record.company_informations += _('\nBuilding Number: %s, Plot Identification: %s \nNeighborhood: %s') % (self.company_id.l10n_sa_edi_building_number, self.company_id.l10n_sa_edi_plot_identification, self.company_id.street2)
diff --git a/addons/l10n_sa_edi/models/res_partner.py b/addons/l10n_sa_edi/models/res_partner.py
new file mode 100644
index 00000000000..c49d5bda4b1
--- /dev/null
+++ b/addons/l10n_sa_edi/models/res_partner.py
@@ -0,0 +1,36 @@
+from odoo import fields, models, api
+
+
+class ResPartner(models.Model):
+ _inherit = 'res.partner'
+
+ l10n_sa_edi_building_number = fields.Char("Building Number")
+ l10n_sa_edi_plot_identification = fields.Char("Plot Identification")
+
+ l10n_sa_additional_identification_scheme = fields.Selection([
+ ('TIN', 'Tax Identification Number'),
+ ('CRN', 'Commercial Registration Number'),
+ ('MOM', 'Momra License'),
+ ('MLS', 'MLSD License'),
+ ('700', '700 Number'),
+ ('SAG', 'Sagia License'),
+ ('NAT', 'National ID'),
+ ('GCC', 'GCC ID'),
+ ('IQA', 'Iqama Number'),
+ ('PAS', 'Passport ID'),
+ ('OTH', 'Other ID')
+ ], default="OTH", string="Identification Scheme", help="Additional Identification scheme for Seller/Buyer")
+
+ l10n_sa_additional_identification_number = fields.Char("Identification Number (SA)",
+ help="Additional Identification Number for Seller/Buyer")
+
+ @api.model
+ def _commercial_fields(self):
+ return super()._commercial_fields() + ['l10n_sa_edi_building_number',
+ 'l10n_sa_edi_plot_identification',
+ 'l10n_sa_additional_identification_scheme',
+ 'l10n_sa_additional_identification_number']
+
+ def _address_fields(self):
+ return super()._address_fields() + ['l10n_sa_edi_building_number',
+ 'l10n_sa_edi_plot_identification']
diff --git a/addons/l10n_sa_edi/security/ir.model.access.csv b/addons/l10n_sa_edi/security/ir.model.access.csv
new file mode 100644
index 00000000000..58c6aabd2c8
--- /dev/null
+++ b/addons/l10n_sa_edi/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+l10n_sa_edi_otp_wizard,l10n_sa_edi_otp_wizard,model_l10n_sa_edi_otp_wizard,account.group_account_invoice,1,1,1,0
diff --git a/addons/l10n_sa_edi/static/src/scss/form_view.scss b/addons/l10n_sa_edi/static/src/scss/form_view.scss
new file mode 100644
index 00000000000..dcaad578cb0
--- /dev/null
+++ b/addons/l10n_sa_edi/static/src/scss/form_view.scss
@@ -0,0 +1,21 @@
+.o_form_view {
+
+ .o_address_format {
+ .o_address_building_number,
+ .o_address_plot_identification {
+ margin-right: 2%;
+ }
+ }
+
+ &.o_form_editable .o_address_format {
+ .o_address_building_number {
+ width: 48%;
+ }
+
+ .o_address_plot_identification {
+ width: 48%;
+ margin-right: 0;
+ }
+ }
+
+}
diff --git a/addons/l10n_sa_edi/tests/__init__.py b/addons/l10n_sa_edi/tests/__init__.py
new file mode 100644
index 00000000000..f64672ea767
--- /dev/null
+++ b/addons/l10n_sa_edi/tests/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import common
+from . import test_edi_zatca
diff --git a/addons/l10n_sa_edi/tests/common.py b/addons/l10n_sa_edi/tests/common.py
new file mode 100644
index 00000000000..8ac096a7110
--- /dev/null
+++ b/addons/l10n_sa_edi/tests/common.py
@@ -0,0 +1,244 @@
+# coding: utf-8
+from datetime import datetime
+
+from odoo import Command
+from odoo.tests import tagged
+from odoo.addons.account_edi.tests.common import AccountEdiTestCommon
+
+
+@tagged('post_install_l10n', '-at_install', 'post_install')
+class TestSaEdiCommon(AccountEdiTestCommon):
+
+ @classmethod
+ def setUpClass(cls, chart_template_ref='sa', edi_format_ref='l10n_sa_edi.edi_sa_zatca'):
+ super().setUpClass(chart_template_ref=chart_template_ref, edi_format_ref=edi_format_ref)
+ # Setup company
+ cls.company = cls.company_data['company']
+ cls.company.name = 'SA Company Test'
+ cls.company.country_id = cls.env.ref('base.sa')
+ cls.company.email = "info@company.saexample.com"
+ cls.company.phone = '+966 51 234 5678'
+ cls.customer_invoice_journal = cls.env['account.journal'].search([('company_id', '=', cls.company.id), ('type', '=', 'sale')], limit=1)
+ cls.company.l10n_sa_edi_building_number = '1234'
+ cls.company.l10n_sa_edi_plot_identification = '1234'
+ cls.company.street2 = "Testomania"
+ cls.company.l10n_sa_additional_identification_number = '2525252525252'
+ cls.company.l10n_sa_additional_identification_scheme = 'CRN'
+ cls.company.vat = '311111111111113'
+ cls.company.l10n_sa_private_key = cls.env['res.company']._l10n_sa_generate_private_key()
+ cls.company.state_id = cls.env['res.country.state'].create({
+ 'name': 'Riyadh',
+ 'code': 'RYA',
+ 'country_id': cls.company.country_id.id
+ })
+ cls.company.street = 'Al Amir Mohammed Bin Abdul Aziz Street'
+ cls.company.city = 'المدينة المنورة'
+ cls.company.zip = '42317'
+ cls.customer_invoice_journal.l10n_sa_serial_number = '123456789'
+ cls.partner_us = cls.env['res.partner'].create({
+ 'name': 'Chichi Lboukla',
+ 'ref': 'Azure Interior',
+ 'street': '4557 De Silva St',
+ 'l10n_sa_edi_building_number': '12300',
+ 'l10n_sa_edi_plot_identification': '2323',
+ 'l10n_sa_additional_identification_scheme': 'CRN',
+ 'l10n_sa_additional_identification_number': '353535353535353',
+ 'city': 'Fremont',
+ 'zip': '94538',
+ 'street2': 'Neighbor!',
+ 'country_id': cls.env.ref('base.us').id,
+ 'state_id': cls.env['res.country.state'].search([('name', '=', 'California')]).id,
+ 'email': 'azure.Interior24@example.com',
+ 'phone': '(870)-931-0505',
+ 'company_type': 'company',
+ 'lang': 'en_US',
+ })
+
+ cls.partner_sa = cls.env['res.partner'].create({
+ 'name': 'Chichi Lboukla',
+ 'ref': 'Azure Interior',
+ 'street': '4557 De Silva St',
+ 'l10n_sa_edi_building_number': '12300',
+ 'l10n_sa_edi_plot_identification': '2323',
+ 'l10n_sa_additional_identification_scheme': 'CRN',
+ 'l10n_sa_additional_identification_number': '353535353535353',
+ 'city': 'Fremont',
+ 'zip': '94538',
+ 'street2': 'Neighbor!',
+ 'country_id': cls.env.ref('base.sa').id,
+ 'state_id': cls.env['res.country.state'].search([('name', '=', 'California')]).id,
+ 'email': 'azure.Interior24@example.com',
+ 'phone': '(870)-931-0505',
+ 'company_type': 'company',
+ 'lang': 'en_US',
+ })
+
+ cls.partner_sa_simplified = cls.env['res.partner'].create({
+ 'name': 'Mohammed Ali',
+ 'ref': 'Mohammed Ali',
+ 'country_id': cls.env.ref('base.sa').id,
+ 'l10n_sa_additional_identification_scheme': 'MOM',
+ 'l10n_sa_additional_identification_number': '3123123213131',
+ 'state_id': cls.company.state_id.id,
+ 'company_type': 'person',
+ 'lang': 'en_US',
+ })
+
+ # 15% tax
+ cls.tax_15 = cls.env['account.tax'].search([('company_id', '=', cls.company.id), ('amount', '=', 15.0)], limit=1)
+
+ # Large cabinet product
+ cls.product_a = cls.env['product.product'].create({
+ 'name': 'Product A',
+ 'uom_id': cls.env.ref('uom.product_uom_unit').id,
+ 'standard_price': 320.0,
+ 'default_code': 'P0001',
+ })
+ cls.product_b = cls.env['product.product'].create({
+ 'name': 'Product B',
+ 'uom_id': cls.env.ref('uom.product_uom_unit').id,
+ 'standard_price': 15.8,
+ 'default_code': 'P0002',
+ })
+
+ cls.product_burger = cls.env['product.product'].create({
+ 'name': 'Burger',
+ 'uom_id': cls.env.ref('uom.product_uom_unit').id,
+ 'standard_price': 265.00,
+ })
+
+ cls.remove_ubl_extensions_xpath = ''''''
+
+ cls.invoice_applied_xpath = '''
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+ '''
+
+ cls.credit_note_applied_xpath = '''
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+ '''
+
+ cls.debit_note_applied_xpath = '''
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+
+ ___ignore___
+
+ '''
+
+ def _create_invoice(self, **kwargs):
+ vals = {
+ 'name': kwargs['name'],
+ 'move_type': 'out_invoice',
+ 'company_id': self.company.id,
+ 'partner_id': kwargs['partner_id'].id,
+ 'invoice_date': kwargs['date'],
+ 'invoice_date_due': kwargs['date_due'],
+ 'currency_id': self.company.currency_id.id,
+ 'invoice_line_ids': [Command.create({
+ 'product_id': kwargs['product_id'].id,
+ 'price_unit': kwargs['price'],
+ 'quantity': kwargs.get('quantity', 1.0),
+ 'tax_ids': [Command.set(self.tax_15.ids)],
+ }),
+ ],
+ }
+ move = self.env['account.move'].create(vals)
+ move.state = 'posted'
+ move.l10n_sa_confirmation_datetime = datetime.now()
+ # move.payment_reference = move.name
+ return move
+
+ def _create_debit_note(self, **kwargs):
+ invoice = self._create_invoice(**kwargs)
+
+ debit_note_wizard = self.env['account.debit.note'].with_context(
+ {'active_ids': [invoice.id], 'active_model': 'account.move', 'default_copy_lines': True}).create({
+ 'reason': 'Totes forgot'})
+ res = debit_note_wizard.create_debit()
+ debit_note = self.env['account.move'].browse(res['res_id'])
+ debit_note.l10n_sa_confirmation_datetime = datetime.now()
+ debit_note.state = 'posted'
+ return debit_note
+
+ def _create_credit_note(self, **kwargs):
+ move = self._create_invoice(**kwargs)
+ move_reversal = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=move.ids).create({
+ 'reason': 'no reason',
+ 'journal_id': move.journal_id.id,
+ })
+ reversal = move_reversal.reverse_moves()
+ reverse_move = self.env['account.move'].browse(reversal['res_id'])
+ reverse_move.l10n_sa_confirmation_datetime = datetime.now()
+ reverse_move.state = 'posted'
+ return reverse_move
diff --git a/addons/l10n_sa_edi/tests/compliance/simplified/credit.xml b/addons/l10n_sa_edi/tests/compliance/simplified/credit.xml
new file mode 100644
index 00000000000..5458e9d9585
--- /dev/null
+++ b/addons/l10n_sa_edi/tests/compliance/simplified/credit.xml
@@ -0,0 +1,225 @@
+
+ 2.1
+ reporting:1.0
+ RINV/2023/00005
+ 790b6c13-72c1-4dac-9f23-6e9d3b43e151
+ 2023-03-10
+ 14:59:38
+ 381
+ SAR
+ SAR
+ Mohammed Ali
+
+ Test
+
+
+
+ INV/2023/00034
+
+
+
+ QR
+
+ N/A
+
+
+
+ PIH
+
+ NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==
+
+
+
+ ICV
+ 0
+
+
+ urn:oasis:names:specification:ubl:signature:Invoice
+ urn:oasis:names:specification:ubl:dsig:enveloped:xades
+
+
+
+
+ 2525252525252
+
+
+ SA Company Test
+
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ SA Company Test
+ 311111111111113
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ VAT
+
+
+
+ SA Company Test
+ 311111111111113
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+
+ 1
+ SA Company Test
+ +966 51 234 5678
+ info@company.saexample.com
+
+
+
+
+
+
+ 3123123213131
+
+
+ Mohammed Ali
+
+
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ Mohammed Ali
+
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ VAT
+
+
+
+ Mohammed Ali
+
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+
+ 7
+ Mohammed Ali
+
+
+
+
+ 2023-03-10
+
+
+ 1
+ 2023-03-10
+ Accounting Mistake
+ RINV/2023/00005
+
+
+ 119.25
+
+ 795.00
+ 119.25
+ 15.0
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+
+ 119.25
+
+
+ 795.00
+ 795.00
+ 914.25
+ 0.00
+ 914.25
+
+
+ 167
+ 3.0
+ 795.00
+
+ 119.25
+ 914.25
+
+ 795.00
+ 119.25
+ 15.0
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+
+ Burger
+ Burger
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+ 265.00
+
+
+
diff --git a/addons/l10n_sa_edi/tests/compliance/simplified/debit.xml b/addons/l10n_sa_edi/tests/compliance/simplified/debit.xml
new file mode 100644
index 00000000000..ae57f6ea68a
--- /dev/null
+++ b/addons/l10n_sa_edi/tests/compliance/simplified/debit.xml
@@ -0,0 +1,226 @@
+
+ 2.1
+ reporting:1.0
+ INV/2023/00035
+ 47fdc8c6-2346-460a-8231-c28bf3bab44c
+ 2023-03-10
+ 15:01:46
+ 383
+ SAR
+ SAR
+ Mohammed Ali
+
+ Test
+
+
+
+ INV/2023/00034
+
+
+
+ QR
+
+ N/A
+
+
+
+ PIH
+
+ NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==
+
+
+
+ ICV
+ 0
+
+
+ urn:oasis:names:specification:ubl:signature:Invoice
+ urn:oasis:names:specification:ubl:dsig:enveloped:xades
+
+
+
+
+ 2525252525252
+
+
+ SA Company Test
+
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ SA Company Test
+ 311111111111113
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ VAT
+
+
+
+ SA Company Test
+ 311111111111113
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+
+ 1
+ SA Company Test
+ +966 51 234 5678
+ info@company.saexample.com
+
+
+
+
+
+
+ 3123123213131
+
+
+ Mohammed Ali
+
+
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ Mohammed Ali
+
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ VAT
+
+
+
+ Mohammed Ali
+
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+
+ 7
+ Mohammed Ali
+
+
+
+
+ 2023-03-10
+
+
+ 1
+ 2023-03-10
+ INV/2023/00035
+ More Burgers
+ INV/2023/00035
+
+
+ 79.50
+
+ 530.00
+ 79.50
+ 15.0
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+
+ 79.50
+
+
+ 530.00
+ 530.00
+ 609.50
+ 0.00
+ 609.50
+
+
+ 170
+ 2.0
+ 530.00
+
+ 79.50
+ 609.50
+
+ 530.00
+ 79.50
+ 15.0
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+
+ Burger
+ Burger
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+ 265.00
+
+
+
diff --git a/addons/l10n_sa_edi/tests/compliance/simplified/invoice.xml b/addons/l10n_sa_edi/tests/compliance/simplified/invoice.xml
new file mode 100644
index 00000000000..4a0ed4d07ca
--- /dev/null
+++ b/addons/l10n_sa_edi/tests/compliance/simplified/invoice.xml
@@ -0,0 +1,218 @@
+
+ 2.1
+ reporting:1.0
+ INV/2023/00034
+ 22b851e5-6fd9-47af-807e-78ef1526667d
+ 2023-03-10
+ 14:56:55
+ 388
+ SAR
+ SAR
+ Mohammed Ali
+
+ QR
+
+ N/A
+
+
+
+ PIH
+
+ NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==
+
+
+
+ ICV
+ 0
+
+
+ urn:oasis:names:specification:ubl:signature:Invoice
+ urn:oasis:names:specification:ubl:dsig:enveloped:xades
+
+
+
+
+ 2525252525252
+
+
+ SA Company Test
+
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ SA Company Test
+ 311111111111113
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ VAT
+
+
+
+ SA Company Test
+ 311111111111113
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+
+ 1
+ SA Company Test
+ +966 51 234 5678
+ info@company.saexample.com
+
+
+
+
+
+
+ 3123123213131
+
+
+ Mohammed Ali
+
+
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ Mohammed Ali
+
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ VAT
+
+
+
+ Mohammed Ali
+
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+
+ 7
+ Mohammed Ali
+
+
+
+
+ 2023-03-10
+
+
+ 1
+ 2023-03-10
+ INV/2023/00034
+ INV/2023/00034
+
+
+ 119.25
+
+ 795.00
+ 119.25
+ 15.0
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+
+ 119.25
+
+
+ 795.00
+ 795.00
+ 914.25
+ 0.00
+ 914.25
+
+
+ 164
+ 3.0
+ 795.00
+
+ 119.25
+ 914.25
+
+ 795.00
+ 119.25
+ 15.0
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+
+ Burger
+ Burger
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+ 265.00
+
+
+
+
diff --git a/addons/l10n_sa_edi/tests/compliance/standard/credit.xml b/addons/l10n_sa_edi/tests/compliance/standard/credit.xml
new file mode 100644
index 00000000000..d1c023fe450
--- /dev/null
+++ b/addons/l10n_sa_edi/tests/compliance/standard/credit.xml
@@ -0,0 +1,222 @@
+
+ 2.1
+ reporting:1.0
+ RINV/2022/00006
+ 6c49b8e0-2ce5-11ed-b6c7-c54ae37ec60b
+ 2022-09-05
+ 09:39:15
+ 381
+ SAR
+ SAR
+ Azure Interior
+
+ test
+
+
+
+ test
+
+
+
+ PIH
+
+
+ NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==
+
+
+
+ ICV
+ 137
+
+
+
+
+ 2525252525252
+
+
+ SA Company Test
+
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ SA Company Test
+ 311111111111113
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ VAT
+
+
+
+ SA Company Test
+ 311111111111113
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+
+ 337
+ SA Company Test
+ +966 51 234 5678
+ info@company.saexample.com
+
+
+
+
+
+
+ 353535353535353
+
+
+ Chichi Lboukla
+
+
+ 4557 De Silva St
+ 12300
+ 2323
+ Neighbor!
+ Fremont
+ 94538
+ California
+ CA
+
+ US
+ United States
+
+
+
+ Chichi Lboukla
+
+ 4557 De Silva St
+ 12300
+ 2323
+ Neighbor!
+ Fremont
+ 94538
+ California
+ CA
+
+ US
+ United States
+
+
+
+
+ 340
+ Chichi Lboukla
+ (870)-931-0505
+ azure.Interior24@example.com
+
+
+
+
+ 2022-09-05
+
+
+ 1
+ 2022-09-05
+ Ttest
+ RINV/2022/00002
+
+
+ 48.00
+
+ 320.00
+ 48.00
+ 15.0
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+
+ 48.00
+
+
+ 320.00
+ 320.00
+ 368.00
+ 0.00
+ 368.00
+
+
+ 390
+ 1.0
+ 320.00
+
+ 48.00
+ 368.00
+
+ 320.00
+ 48.00
+ 15.0
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+
+ [P0001] Product A
+ Product A
+
+ P0001
+
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+ 320.00
+
+
+
diff --git a/addons/l10n_sa_edi/tests/compliance/standard/debit.xml b/addons/l10n_sa_edi/tests/compliance/standard/debit.xml
new file mode 100644
index 00000000000..5ede0d7ac44
--- /dev/null
+++ b/addons/l10n_sa_edi/tests/compliance/standard/debit.xml
@@ -0,0 +1,223 @@
+
+ 2.1
+ reporting:1.0
+ INV/2022/00015
+ 4dfa4796-2ce6-11ed-b6c7-c54ae37ec60b
+ 2022-09-05
+ 09:45:27
+ 383
+ SAR
+ SAR
+ Azure Interior
+
+ INV/2022/00014, Totes forgot
+
+
+
+ INV/2022/00014
+
+
+
+ PIH
+
+
+ NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==
+
+
+
+ ICV
+ 138
+
+
+
+
+ 2525252525252
+
+
+ SA Company Test
+
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ SA Company Test
+ 311111111111113
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ VAT
+
+
+
+ SA Company Test
+ 311111111111113
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+
+ 547
+ SA Company Test
+ +966 51 234 5678
+ info@company.saexample.com
+
+
+
+
+
+
+ 353535353535353
+
+
+ Chichi Lboukla
+
+
+ 4557 De Silva St
+ 12300
+ 2323
+ Neighbor!
+ Fremont
+ 94538
+ California
+ CA
+
+ US
+ United States
+
+
+
+ Chichi Lboukla
+
+ 4557 De Silva St
+ 12300
+ 2323
+ Neighbor!
+ Fremont
+ 94538
+ California
+ CA
+
+ US
+ United States
+
+
+
+
+ 550
+ Chichi Lboukla
+ (870)-931-0505
+ azure.Interior24@example.com
+
+
+
+
+ 2022-09-05
+
+
+ 1
+ 2022-09-05
+ INV/2022/00015
+ INV/2022/00015, Totes forgot
+ INV/2022/00015
+
+
+ 2.37
+
+ 15.80
+ 2.37
+ 15.0
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+
+ 2.37
+
+
+ 15.80
+ 15.80
+ 18.17
+ 0.00
+ 18.17
+
+
+ 393
+ 1.0
+ 15.80
+
+ 2.37
+ 18.17
+
+ 15.80
+ 2.37
+ 15.0
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+
+ [P0002] Product B
+ Product B
+
+ P0002
+
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+ 15.80
+
+
+
diff --git a/addons/l10n_sa_edi/tests/compliance/standard/invoice.xml b/addons/l10n_sa_edi/tests/compliance/standard/invoice.xml
new file mode 100644
index 00000000000..a34768f15e7
--- /dev/null
+++ b/addons/l10n_sa_edi/tests/compliance/standard/invoice.xml
@@ -0,0 +1,213 @@
+
+ 2.1
+ reporting:1.0
+ INV/2022/00014
+ ff608a28-096e-44a1-a896-cbb52212a8a3
+ 2022-09-05
+ 08:20:02
+ 388
+ SAR
+ SAR
+ Azure Interior
+
+ PIH
+
+ NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==
+
+
+
+ ICV
+ 0
+
+
+
+
+ 2525252525252
+
+
+ SA Company Test
+
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ SA Company Test
+ 311111111111113
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+ VAT
+
+
+
+ SA Company Test
+ 311111111111113
+
+ Al Amir Mohammed Bin Abdul Aziz Street
+ 1234
+ 1234
+ Testomania
+ المدينة المنورة
+ 42317
+ Riyadh
+ RYA
+
+ SA
+ Saudi Arabia
+
+
+
+
+ 33
+ SA Company Test
+ +966 51 234 5678
+ info@company.saexample.com
+
+
+
+
+
+
+ 353535353535353
+
+
+ Chichi Lboukla
+
+
+ 4557 De Silva St
+ 12300
+ 2323
+ Neighbor!
+ Fremont
+ 94538
+ California
+ CA
+
+ US
+ United States
+
+
+
+ Chichi Lboukla
+
+ 4557 De Silva St
+ 12300
+ 2323
+ Neighbor!
+ Fremont
+ 94538
+ California
+ CA
+
+ US
+ United States
+
+
+
+
+ 42
+ Chichi Lboukla
+ (870)-931-0505
+ azure.Interior24@example.com
+
+
+
+
+ 2022-09-05
+
+
+ 1
+ 2022-09-22
+ INV/2022/00014
+ INV/2022/00014
+
+
+ 48.00
+
+ 320.00
+ 48.00
+ 15.0
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+
+ 48.00
+
+
+ 320.00
+ 320.00
+ 368.00
+ 0.00
+ 368.00
+
+
+ 384
+ 1.0
+ 320.00
+
+ 48.00
+ 368.00
+
+ 320.00
+ 48.00
+ 15.0
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+
+ [P0001] Product A
+ Product A
+
+ P0001
+
+
+ S
+ 15.0
+
+ VAT
+
+
+
+
+ 320.00
+
+
+
diff --git a/addons/l10n_sa_edi/tests/test_edi_zatca.py b/addons/l10n_sa_edi/tests/test_edi_zatca.py
new file mode 100644
index 00000000000..fc79a3c7e2d
--- /dev/null
+++ b/addons/l10n_sa_edi/tests/test_edi_zatca.py
@@ -0,0 +1,124 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from datetime import datetime
+from freezegun import freeze_time
+import logging
+from pytz import timezone
+
+from odoo.tests import tagged
+from odoo.tools import misc
+
+from .common import TestSaEdiCommon
+
+_logger = logging.getLogger(__name__)
+
+
+@tagged('post_install_l10n', '-at_install', 'post_install')
+class TestEdiZatca(TestSaEdiCommon):
+
+ def testInvoiceStandard(self):
+
+ with freeze_time(datetime(year=2022, month=9, day=5, hour=8, minute=20, second=2, tzinfo=timezone('Etc/GMT-3'))):
+ standard_invoice = misc.file_open('l10n_sa_edi/tests/compliance/standard/invoice.xml', 'rb').read()
+ expected_tree = self.get_xml_tree_from_string(standard_invoice)
+ expected_tree = self.with_applied_xpath(expected_tree, self.invoice_applied_xpath)
+
+ move = self._create_invoice(name='INV/2022/00014', date='2022-09-05', date_due='2022-09-22', partner_id=self.partner_us,
+ product_id=self.product_a, price=320.0)
+ move._l10n_sa_generate_unsigned_data()
+ generated_file = self.env['account.edi.format']._l10n_sa_generate_zatca_template(move)
+ current_tree = self.get_xml_tree_from_string(generated_file)
+ current_tree = self.with_applied_xpath(current_tree, self.remove_ubl_extensions_xpath)
+
+ self.assertXmlTreeEqual(current_tree, expected_tree)
+
+ def testCreditNoteStandard(self):
+
+ with freeze_time(datetime(year=2022, month=9, day=5, hour=9, minute=39, second=15, tzinfo=timezone('Etc/GMT-3'))):
+ applied_xpath = self.credit_note_applied_xpath + \
+ '''
+
+ ___ignore___
+
+ '''
+
+ standard_credit_note = misc.file_open('l10n_sa_edi/tests/compliance/standard/credit.xml', 'rb').read()
+ expected_tree = self.get_xml_tree_from_string(standard_credit_note)
+ expected_tree = self.with_applied_xpath(expected_tree, applied_xpath)
+
+ credit_note = self._create_credit_note(name='INV/2022/00014', date='2022-09-05', date_due='2022-09-22',
+ partner_id=self.partner_us, product_id=self.product_a, price=320.0)
+ credit_note._l10n_sa_generate_unsigned_data()
+ generated_file = self.env['account.edi.format']._l10n_sa_generate_zatca_template(credit_note)
+ current_tree = self.get_xml_tree_from_string(generated_file)
+ current_tree = self.with_applied_xpath(current_tree, self.remove_ubl_extensions_xpath)
+
+ self.assertXmlTreeEqual(current_tree, expected_tree)
+
+ def testDebitNoteStandard(self):
+ with freeze_time(datetime(year=2022, month=9, day=5, hour=9, minute=45, second=27, tzinfo=timezone('Etc/GMT-3'))):
+ applied_xpath = self.debit_note_applied_xpath + \
+ '''
+
+ ___ignore___
+
+ '''
+
+ standard_debit_note = misc.file_open('l10n_sa_edi/tests/compliance/standard/debit.xml', 'rb').read()
+ expected_tree = self.get_xml_tree_from_string(standard_debit_note)
+ expected_tree = self.with_applied_xpath(expected_tree, applied_xpath)
+
+ debit_note = self._create_debit_note(name='INV/2022/00001', date='2022-09-05', date_due='2022-09-22',
+ partner_id=self.partner_us, product_id=self.product_b, price=15.80)
+ debit_note._l10n_sa_generate_unsigned_data()
+ generated_file = self.env['account.edi.format']._l10n_sa_generate_zatca_template(debit_note)
+ current_tree = self.get_xml_tree_from_string(generated_file)
+ current_tree = self.with_applied_xpath(current_tree, self.remove_ubl_extensions_xpath)
+
+ self.assertXmlTreeEqual(current_tree, expected_tree)
+
+ def testInvoiceSimplified(self):
+ with freeze_time(datetime(year=2023, month=3, day=10, hour=14, minute=56, second=55, tzinfo=timezone('Etc/GMT-3'))):
+ simplified_invoice = misc.file_open('l10n_sa_edi/tests/compliance/simplified/invoice.xml', 'rb').read()
+ expected_tree = self.get_xml_tree_from_string(simplified_invoice)
+ expected_tree = self.with_applied_xpath(expected_tree, self.invoice_applied_xpath)
+
+ move = self._create_invoice(name='INV/2023/00034', date='2023-03-10', date_due='2023-03-10', partner_id=self.partner_sa_simplified,
+ product_id=self.product_burger, price=265.00, quantity=3.0)
+ move._l10n_sa_generate_unsigned_data()
+ generated_file = self.env['account.edi.format']._l10n_sa_generate_zatca_template(move)
+ current_tree = self.get_xml_tree_from_string(generated_file)
+ current_tree = self.with_applied_xpath(current_tree, self.remove_ubl_extensions_xpath)
+
+ self.assertXmlTreeEqual(current_tree, expected_tree)
+
+ def testCreditNoteSimplified(self):
+ with freeze_time(datetime(year=2023, month=3, day=10, hour=14, minute=59, second=38, tzinfo=timezone('Etc/GMT-3'))):
+ simplified_credit_note = misc.file_open('l10n_sa_edi/tests/compliance/simplified/credit.xml', 'rb').read()
+ expected_tree = self.get_xml_tree_from_string(simplified_credit_note)
+ expected_tree = self.with_applied_xpath(expected_tree, self.credit_note_applied_xpath)
+
+ move = self._create_credit_note(name='INV/2023/00034', date='2023-03-10', date_due='2023-03-10',
+ partner_id=self.partner_sa_simplified, product_id=self.product_burger,
+ price=265.00, quantity=3.0)
+ move._l10n_sa_generate_unsigned_data()
+ generated_file = self.env['account.edi.format']._l10n_sa_generate_zatca_template(move)
+ current_tree = self.get_xml_tree_from_string(generated_file)
+ current_tree = self.with_applied_xpath(current_tree, self.remove_ubl_extensions_xpath)
+
+ self.assertXmlTreeEqual(current_tree, expected_tree)
+
+ def testDebitNoteSimplified(self):
+ with freeze_time(datetime(year=2023, month=3, day=10, hour=15, minute=1, second=46, tzinfo=timezone('Etc/GMT-3'))):
+ simplified_credit_note = misc.file_open('l10n_sa_edi/tests/compliance/simplified/debit.xml', 'rb').read()
+ expected_tree = self.get_xml_tree_from_string(simplified_credit_note)
+ expected_tree = self.with_applied_xpath(expected_tree, self.debit_note_applied_xpath)
+
+ move = self._create_debit_note(name='INV/2023/00034', date='2023-03-10', date_due='2023-03-10',
+ partner_id=self.partner_sa_simplified, product_id=self.product_burger,
+ price=265.00, quantity=2.0)
+ move._l10n_sa_generate_unsigned_data()
+ generated_file = self.env['account.edi.format']._l10n_sa_generate_zatca_template(move)
+ current_tree = self.get_xml_tree_from_string(generated_file)
+ current_tree = self.with_applied_xpath(current_tree, self.remove_ubl_extensions_xpath)
+
+ self.assertXmlTreeEqual(current_tree, expected_tree)
diff --git a/addons/l10n_sa_edi/views/account_journal_views.xml b/addons/l10n_sa_edi/views/account_journal_views.xml
new file mode 100644
index 00000000000..6cd81353c63
--- /dev/null
+++ b/addons/l10n_sa_edi/views/account_journal_views.xml
@@ -0,0 +1,94 @@
+
+
+
+
+
+ account.journal.form.l10n_sa_edi
+ account.journal
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ In order to be able to submit Invoices to ZATCA, the following steps need to be completed:
+
+
+
+ Set a Serial Number for your device
+
+
+
+ Request a Compliance Certificate (CCSID)
+
+
+
+ Complete the Compliance Checks
+
+
+
+ Request a Production Certificate (PCSID)
+
+
+
+
+
+
+ Onboard the Journal by completing each step
+
+
+
+
+
+
Journal could not be onboarded. Please make sure the Company VAT/Identification Number are correct.
+
+
+
+
+
+
+
+ The Production certificate is valid until
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/addons/l10n_sa_edi/views/account_tax_views.xml b/addons/l10n_sa_edi/views/account_tax_views.xml
new file mode 100644
index 00000000000..ea4a5ff5d3c
--- /dev/null
+++ b/addons/l10n_sa_edi/views/account_tax_views.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ account.tax.form.zatca
+ account.tax
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/l10n_sa_edi/views/report_invoice.xml b/addons/l10n_sa_edi/views/report_invoice.xml
new file mode 100644
index 00000000000..1c803086773
--- /dev/null
+++ b/addons/l10n_sa_edi/views/report_invoice.xml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+ You can select the API used for submissions down below. There are three modes available: Sandbox, Pre-Production and Production.
+ Once you have selected the correct API, you can start the Onboarding process by going to the Journals and checking the options under the ZATCA tab.
+
+
+
+
+
+
+
+
+
+ Warning
+
+ Once you change the submission mode to Production, you cannot change it anymore.
+ Be very careful, as any invoice submitted to ZATCA in Production mode will be accounted for
+ and might lead to Fines & Penalties.
+
+
+
+
+
+
+
+
diff --git a/addons/l10n_sa_edi/views/res_partner_views.xml b/addons/l10n_sa_edi/views/res_partner_views.xml
new file mode 100644
index 00000000000..56d652d4396
--- /dev/null
+++ b/addons/l10n_sa_edi/views/res_partner_views.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+ res.partner.l10n_sa_edi.form
+ res.partner
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/l10n_sa_edi/wizard/__init__.py b/addons/l10n_sa_edi/wizard/__init__.py
new file mode 100644
index 00000000000..55a601f2cee
--- /dev/null
+++ b/addons/l10n_sa_edi/wizard/__init__.py
@@ -0,0 +1,3 @@
+from . import account_move_reversal
+from . import account_debit_note
+from . import l10n_sa_edi_otp_wizard
diff --git a/addons/l10n_sa_edi/wizard/account_debit_note.py b/addons/l10n_sa_edi/wizard/account_debit_note.py
new file mode 100644
index 00000000000..f3f7b87750b
--- /dev/null
+++ b/addons/l10n_sa_edi/wizard/account_debit_note.py
@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+from odoo import models
+from odoo.tools.translate import _
+from odoo.exceptions import UserError
+
+
+class AccountDebitNote(models.TransientModel):
+ _inherit = 'account.debit.note'
+
+ def create_debit(self):
+ self.ensure_one()
+ for move in self.move_ids:
+ if move.journal_id.country_code == 'SA' and not self.reason:
+ raise UserError(_("For debit notes issued in Saudi Arabia, you need to specify a Reason"))
+ return super().create_debit()
diff --git a/addons/l10n_sa_edi/wizard/account_move_reversal.py b/addons/l10n_sa_edi/wizard/account_move_reversal.py
new file mode 100644
index 00000000000..6bd2f82f102
--- /dev/null
+++ b/addons/l10n_sa_edi/wizard/account_move_reversal.py
@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+from odoo import models
+from odoo.tools.translate import _
+from odoo.exceptions import UserError
+
+
+class AccountMoveReversal(models.TransientModel):
+ _inherit = 'account.move.reversal'
+
+ def reverse_moves(self, is_modify=False):
+ self.ensure_one()
+ for move in self.move_ids:
+ if move.journal_id.country_code == 'SA' and not self.reason:
+ raise UserError(_("For Credit/Debit notes issued in Saudi Arabia, you need to specify a Reason"))
+ return super().reverse_moves(is_modify=is_modify)
diff --git a/addons/l10n_sa_edi/wizard/l10n_sa_edi_otp_wizard.py b/addons/l10n_sa_edi/wizard/l10n_sa_edi_otp_wizard.py
new file mode 100644
index 00000000000..b4dbda3d9ee
--- /dev/null
+++ b/addons/l10n_sa_edi/wizard/l10n_sa_edi_otp_wizard.py
@@ -0,0 +1,28 @@
+from odoo import fields, models, _, api
+from odoo.exceptions import UserError
+
+
+class RequestZATCAOtp(models.TransientModel):
+ _name = 'l10n_sa_edi.otp.wizard'
+ _description = 'Request ZATCA OTP'
+
+ l10n_sa_renewal = fields.Boolean("PCSID Renewal",
+ help="Used to decide whether we should call the PCSID renewal API or the CCSID API",
+ default=False)
+ l10n_sa_otp = fields.Char("OTP", copy=False, help="OTP required to get a CCSID. Can only be acquired through "
+ "the Fatoora portal.")
+ journal_id = fields.Many2one('account.journal', default=lambda self: self.env.context.get('active_id'), required=True)
+
+ @api.model
+ def default_get(self, fields):
+ res = super().default_get(fields)
+ if self.env.company.l10n_sa_api_mode == 'sandbox':
+ res['l10n_sa_otp'] = '123456' if self.l10n_sa_renewal else '123345'
+ return res
+
+ def validate(self):
+ if not self.l10n_sa_otp:
+ raise UserError(_("You need to provide an OTP to be able to request a CCSID"))
+ if self.l10n_sa_renewal:
+ return self.journal_id._l10n_sa_get_production_CSID(self.l10n_sa_otp)
+ self.journal_id._l10n_sa_api_onboard_journal(self.l10n_sa_otp)
diff --git a/addons/l10n_sa_edi/wizard/l10n_sa_edi_otp_wizard.xml b/addons/l10n_sa_edi/wizard/l10n_sa_edi_otp_wizard.xml
new file mode 100644
index 00000000000..7d7e6b88b31
--- /dev/null
+++ b/addons/l10n_sa_edi/wizard/l10n_sa_edi_otp_wizard.xml
@@ -0,0 +1,30 @@
+
+
+
+
+ l10n_sa_edi.otp.wizard.form
+ l10n_sa_edi.otp.wizard
+
+
+
+
+
+
+ Request a CSID
+ l10n_sa_edi.otp.wizard
+ form
+ new
+
+
+
diff --git a/addons/l10n_sa_invoice/views/report_invoice.xml b/addons/l10n_sa_invoice/views/report_invoice.xml
deleted file mode 100644
index 85e6c891844..00000000000
--- a/addons/l10n_sa_invoice/views/report_invoice.xml
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
-