diff --git a/addons/l10n_sa_edi/__init__.py b/addons/l10n_sa_edi/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f0111a2faf4ae6bb9ac93cc3a7ae115d551620bf --- /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 0000000000000000000000000000000000000000..204db76c416b1e19ba4418bfafb310b316b5d8d3 --- /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', + 'icon': '/l10n_sa/static/description/icon.png', + '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 0000000000000000000000000000000000000000..d340e6ccc0d8ece34606fee6c56d90b2af99d52f --- /dev/null +++ b/addons/l10n_sa_edi/data/account_edi_format.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data noupdate="1"> + + <record id="edi_sa_zatca" model="account.edi.format"> + <field name="name">ZATCA (Saudi Arabia)</field> + <field name="code">sa_zatca</field> + </record> + + </data> +</odoo> 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 0000000000000000000000000000000000000000..6c69d566615d645689fd4d7b723353687f6be9d7 --- /dev/null +++ b/addons/l10n_sa_edi/data/pre-hash_invoice.xsl @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" + xmlns:xs="http://www.w3.org/2001/XMLSchema" + xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" + xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" + xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2" + exclude-result-prefixes="xs" + version="2.0"> + <xsl:output omit-xml-declaration="yes" indent="no"/> + <xsl:template match="node() | @*"> + <xsl:copy> + <xsl:apply-templates select="node() | @*"/> + </xsl:copy> + </xsl:template> + <xsl:template match="//*[local-name()='Invoice']//*[local-name()='UBLExtensions']"></xsl:template> + <xsl:template match="//*[local-name()='AdditionalDocumentReference'][cbc:ID[normalize-space(text()) = 'QR']]"></xsl:template> + <xsl:template match="//*[local-name()='Invoice']//*[local-name()='Signature']"></xsl:template> +</xsl:stylesheet> 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 0000000000000000000000000000000000000000..3e0b47a6dbf47bd37a924f929fa8d4fc6bfb6e2f --- /dev/null +++ b/addons/l10n_sa_edi/data/res_country_data.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="sa_partner_address_form" model="ir.ui.view"> + <field name="name">sa.partner.form.address</field> + <field name="model">res.partner</field> + <field name="priority" eval="900"/> + <field name="arch" type="xml"> + <form> + <div class="o_address_format"> + <field name="parent_id" invisible="1"/> + <field name="type" invisible="1"/> + <field name="street" placeholder="Street" class="o_address_street" + attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}"/> + <field name="street2" placeholder="Neighborhood" class="o_address_street" + attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}"/> + <field name="city" placeholder="City" class="o_address_city" + attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}"/> + <field name="state_id" class="o_address_state" placeholder="State..." options='{"no_open": True}' + attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}"/> + <field name="zip" placeholder="ZIP" class="o_address_zip" + attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}"/> + <field name="country_id" placeholder="Country" class="o_address_country" options='{"no_open": True, "no_create": True}' + attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}"/> + <field name="l10n_sa_edi_building_number" placeholder="Building Number" + class="o_address_building_number" options='{"no_open": True, "no_create": True}' + attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}"/> + <field name="l10n_sa_edi_plot_identification" placeholder="Plot Identification" + class="o_address_plot_identification" options='{"no_open": True, "no_create": True}' + attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}"/> + </div> + </form> + </field> + </record> + <record id="base.sa" model="res.country"> + <field name="address_view_id" ref="sa_partner_address_form" /> + <field name="address_format" eval="'%(street)s\n%(street2)s\n%(city)s %(state_code)s %(zip)s\n%(l10n_sa_edi_building_number)s %(l10n_sa_edi_plot_identification)s\n%(country_name)s'"/> + </record> +</odoo> 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 0000000000000000000000000000000000000000..2ab7ef4076b4f29bf1f56c60df9cf0caf167cc26 --- /dev/null +++ b/addons/l10n_sa_edi/data/ubl_21_zatca.xml @@ -0,0 +1,285 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + + <template id="ubl_21_PaymentMeansType_zatca" inherit_id="account_edi_ubl_cii.ubl_20_PaymentMeansType" primary="True"> + <xpath expr="//*[local-name()='InstructionID']" position="after"> + <cbc:InstructionNote xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" + t-out="vals['adjustment_reason']"/> + </xpath> + </template> + + <template id="export_sa_zatca_ubl_extensions"> + <ext:UBLExtensions xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" + xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2"> + <ext:UBLExtension> + <ext:ExtensionURI>urn:oasis:names:specification:ubl:dsig:enveloped:xades</ext:ExtensionURI> + <ext:ExtensionContent> + <sig:UBLDocumentSignatures + xmlns:sac="urn:oasis:names:specification:ubl:schema:xsd:SignatureAggregateComponents-2" + xmlns:sbc="urn:oasis:names:specification:ubl:schema:xsd:SignatureBasicComponents-2" + xmlns:sig="urn:oasis:names:specification:ubl:schema:xsd:CommonSignatureComponents-2"> + <sac:SignatureInformation> + <cbc:ID>urn:oasis:names:specification:ubl:signature:1</cbc:ID> + <sbc:ReferencedSignatureID>urn:oasis:names:specification:ubl:signature:Invoice + </sbc:ReferencedSignatureID> + <ds:Signature Id="signature" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"> + <ds:SignedInfo> + <ds:CanonicalizationMethod + Algorithm="http://www.w3.org/2006/12/xml-c14n11"/> + <ds:SignatureMethod + Algorithm="http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"/> + <ds:Reference Id="invoiceSignedData" URI=""> + <ds:Transforms> + <ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"> + <ds:XPath>not(//ancestor-or-self::ext:UBLExtensions)</ds:XPath> + </ds:Transform> + <ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"> + <ds:XPath>not(//ancestor-or-self::cac:Signature)</ds:XPath> + </ds:Transform> + <ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"> + <ds:XPath> + not(//ancestor-or-self::cac:AdditionalDocumentReference[cbc:ID='QR']) + </ds:XPath> + </ds:Transform> + <ds:Transform Algorithm="http://www.w3.org/2006/12/xml-c14n11"/> + </ds:Transforms> + <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/> + <!-- b64encoded SHA256 digest of document --> + <ds:DigestValue/> + </ds:Reference> + <ds:Reference Type="http://www.w3.org/2000/09/xmldsig#SignatureProperties" + URI="#xadesSignedProperties"> + <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/> + <ds:DigestValue/> + </ds:Reference> + </ds:SignedInfo> + <ds:SignatureValue/> + <ds:KeyInfo> + <ds:X509Data> + <ds:X509Certificate/> + </ds:X509Data> + </ds:KeyInfo> + <ds:Object> + <xades:QualifyingProperties xmlns:xades="http://uri.etsi.org/01903/v1.3.2#" + Target="signature"> + <xades:SignedProperties xmlns:xades="http://uri.etsi.org/01903/v1.3.2#" + Id="xadesSignedProperties"> + <xades:SignedSignatureProperties> + <xades:SigningTime/> + <xades:SigningCertificate> + <xades:Cert> + <xades:CertDigest> + <ds:DigestMethod + xmlns:ds="http://www.w3.org/2000/09/xmldsig#" + Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/> + <ds:DigestValue + xmlns:ds="http://www.w3.org/2000/09/xmldsig#"/> + </xades:CertDigest> + <xades:IssuerSerial> + <ds:X509IssuerName + xmlns:ds="http://www.w3.org/2000/09/xmldsig#"/> + <ds:X509SerialNumber + xmlns:ds="http://www.w3.org/2000/09/xmldsig#"/> + </xades:IssuerSerial> + </xades:Cert> + </xades:SigningCertificate> + </xades:SignedSignatureProperties> + </xades:SignedProperties> + </xades:QualifyingProperties> + </ds:Object> + </ds:Signature> + </sac:SignatureInformation> + </sig:UBLDocumentSignatures> + </ext:ExtensionContent> + </ext:UBLExtension> + </ext:UBLExtensions> + </template> + + <template id="export_sa_zatca_ubl_signed_properties"> + <xades:SignedProperties xmlns:xades="http://uri.etsi.org/01903/v1.3.2#" Id="xadesSignedProperties"> + <xades:SignedSignatureProperties> + <xades:SigningTime t-out="signing_time"/> + <xades:SigningCertificate> + <xades:Cert> + <xades:CertDigest> + <ds:DigestMethod xmlns:ds="http://www.w3.org/2000/09/xmldsig#" + Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/> + <ds:DigestValue xmlns:ds="http://www.w3.org/2000/09/xmldsig#" + t-out="public_key_hashing"/> + </xades:CertDigest> + <xades:IssuerSerial> + <ds:X509IssuerName xmlns:ds="http://www.w3.org/2000/09/xmldsig#" t-out="issuer_name"/> + <ds:X509SerialNumber xmlns:ds="http://www.w3.org/2000/09/xmldsig#" + t-out="serial_number"/> + </xades:IssuerSerial> + </xades:Cert> + </xades:SigningCertificate> + </xades:SignedSignatureProperties> + </xades:SignedProperties> + </template> + + <template id="ubl_21_InvoiceLineType_zatca" inherit_id="account_edi_ubl_cii.ubl_20_InvoiceLineType" primary="True"> + <!-- Remove SellersItemIdentification if None --> + <xpath expr="//*[local-name()='SellersItemIdentification']" position="replace"> + <cac:SellersItemIdentification t-if="item_vals['sellers_item_identification_vals']['id']" + xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" + xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"> + <cbc:ID t-out="item_vals['sellers_item_identification_vals']['id']"/> + </cac:SellersItemIdentification> + </xpath> + <xpath expr="//*[local-name()='CreditedQuantity']" position="replace"/> + <xpath expr="//*[local-name()='InvoicedQuantity']" position="replace"> + <cbc:InvoicedQuantity + xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" + t-att="vals.get('invoiced_quantity_attrs', {})" + t-out="vals['invoiced_quantity']"/> + </xpath> + <xpath expr="//*[local-name()='LineExtensionAmount']" position="after"> + <cac:DocumentReference + t-if="vals.get('prepayment_vals', {})" + xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" + xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"> + <cbc:ID t-out="vals['prepayment_vals']['prepayment_id']"/> + <cbc:IssueDate t-esc="vals['prepayment_vals']['issue_date'].strftime('%Y-%m-%d')"/> + <cbc:IssueTime t-esc="vals['prepayment_vals']['issue_date'].strftime('%H:%M:%S')"/> + <cbc:DocumentTypeCode t-out="vals['prepayment_vals']['document_type_code']"/> + </cac:DocumentReference> + </xpath> + </template> + + <template id="ubl_21_TaxTotalType_zatca" inherit_id="account_edi_ubl_cii.ubl_20_TaxTotalType" primary="True"> + <xpath expr="//*[local-name()='TaxAmount']" position="after"> + <cbc:RoundingAmount xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" + t-att-currencyID="vals['currency'].name" + t-esc="format_float(vals.get('total_amount_sa'), vals['currency_dp'])"/> + </xpath> + </template> + + <template id="ubl_21_PartyType_zatca" inherit_id="account_edi_ubl_cii.ubl_20_PartyType" primary="True"> + <xpath expr="//*[local-name()='PartyIdentification']" position="replace"> + <cac:PartyIdentification t-if="party_vals.get('id')" + xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" + xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"> + <cbc:ID t-att="party_vals.get('id_attrs', {})" t-out="party_vals['id']"/> + </cac:PartyIdentification> + </xpath> + </template> + + <template id="ubl_21_AddressType_zatca" inherit_id="account_edi_ubl_cii.ubl_20_AddressType" primary="True"> + <!-- AdditionalStreetName causes the Validation SDK to crash, so it has to be removed --> + <xpath expr="//*[local-name()='AdditionalStreetName']" position="replace"/> + <xpath expr="//*[local-name()='StreetName']" position="after"> + <t xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" + xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"> + <!-- Add Building number in compliance with rules KSA-17 (seller)/ KSA-18 (customer) --> + <cbc:BuildingNumber t-out="vals.get('building_number')"/> + <!-- Add Plot identification in compliance with rules KSA-23 (seller)/ KSA-19 (customer) --> + <cbc:PlotIdentification t-out="vals.get('plot_identification')"/> + <!-- Add Neighborhood in compliance with rules KSA-3 (seller)/ KSA-4 (customer) --> + <cbc:CitySubdivisionName t-out="vals.get('neighborhood')"/> + </t> + </xpath> + </template> + + <template id="ubl_21_InvoiceType_zatca" inherit_id="account_edi_ubl_cii.ubl_21_InvoiceType" primary="True"> + + <!-- For ZATCA, we do not use CreditNoteTypeCode or DebitNoteTypeCode tags. We always use InvoiceTypeCode. --> + <xpath expr="//*[local-name()='CreditNoteTypeCode']" position="replace"/> + <xpath expr="//*[local-name()='InvoiceTypeCode']" position="replace"> + <cbc:InvoiceTypeCode xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" + xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" + t-att="vals['invoice_type_code_attrs']" + t-out="vals['invoice_type_code']"/> + </xpath> + + <!-- For ZATCA, we do not use CreditNoteLine or DebitNoteLine tags. We always use InvoiceLine. --> + <xpath expr="//*[local-name()='CreditNoteLine']" position="replace"/> + <xpath expr="//*[local-name()='InvoiceLine']" position="replace"> + <cac:InvoiceLine xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" + xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"> + <t t-call="{{InvoiceLineType_template}}"> + <t t-set="vals" t-value="foreach_vals"/> + </t> + </cac:InvoiceLine> + </xpath> + + <!-- Remove Order Reference if None --> + <xpath expr="//*[local-name()='OrderReference']" position="replace"> + <cac:OrderReference t-if="vals.get('order_reference')" + xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" + xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"> + <cbc:ID t-out="vals['order_reference']"/> + </cac:OrderReference> + </xpath> + + <!-- Add Invoice UUID in compliance with rule BR-KSA-03 --> + <xpath expr="//*[local-name()='ID']" position="after"> + <cbc:UUID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" + xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + t-esc="invoice.l10n_sa_uuid"/> + </xpath> + + <!-- Add Invoice Issue Time in compliance with rule KSA-25 --> + <xpath expr="//*[local-name()='IssueDate']" position="replace"> + <t xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"> + <cbc:IssueDate t-esc="vals['issue_date'].strftime('%Y-%m-%d')"/> + <cbc:IssueTime t-esc="vals['issue_date'].strftime('%H:%M:%S')"/> + </t> + </xpath> + + <!-- Add Tax Currency Code in compliance with rules BR-KSA-EN16931-02 and BR-KSA-68 --> + <xpath expr="//*[local-name()='DocumentCurrencyCode']" position="after"> + <cbc:TaxCurrencyCode xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" + xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + t-esc="invoice.company_currency_id.name"/> + </xpath> + + <!-- Add Previous Invoice Hash & Invoice Counter Value --> + <xpath expr="//*[local-name()='BillingReference']" position="after"> + <t xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" + xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"> + <!-- Add QR Code in compliance with rule BR-KSA-27 --> + <cac:AdditionalDocumentReference t-if="invoice._l10n_sa_is_simplified()"> + <cbc:ID>QR</cbc:ID> + <cac:Attachment> + <cbc:EmbeddedDocumentBinaryObject mimeCode="text/plain">N/A</cbc:EmbeddedDocumentBinaryObject> + </cac:Attachment> + </cac:AdditionalDocumentReference> + <!-- Add Previous Invoice Hash in compliance with rule BR-KSA-61 --> + <cac:AdditionalDocumentReference> + <cbc:ID>PIH</cbc:ID> + <cac:Attachment> + <cbc:EmbeddedDocumentBinaryObject mimeCode="text/plain" + t-out="vals['previous_invoice_hash']"/> + </cac:Attachment> + </cac:AdditionalDocumentReference> + <!-- Add Invoice Counter Value in compliance with rules BR-KSA-33 and BR-KSA-34 --> + <cac:AdditionalDocumentReference> + <cbc:ID>ICV</cbc:ID> + <cbc:UUID t-out="invoice.l10n_sa_chain_index"/> + </cac:AdditionalDocumentReference> + <!-- Add Signature references in compliance with rules BR-KSA-29 and BR-KSA-30 --> + <cac:Signature t-if="invoice._l10n_sa_is_simplified()"> + <cbc:ID>urn:oasis:names:specification:ubl:signature:Invoice</cbc:ID> + <cbc:SignatureMethod>urn:oasis:names:specification:ubl:dsig:enveloped:xades</cbc:SignatureMethod> + </cac:Signature> + </t> + </xpath> + </template> + + </data> +</odoo> 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 0000000000000000000000000000000000000000..cf8cb4c14c01893ff9c60c9912da547cc5eeb8b3 --- /dev/null +++ b/addons/l10n_sa_edi/demo/demo_company.xml @@ -0,0 +1,52 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + + <record id="l10n_sa.partner_demo_company_sa" model="res.partner"> + <field name="vat">310175397400003</field> + <field name="state_id" ref="base.state_sa_70"/> + <field name="street2">Somewhere close to Mecca</field> + <field name="l10n_sa_edi_building_number">1234</field> + <field name="l10n_sa_edi_plot_identification">1234</field> + <field name="l10n_sa_additional_identification_scheme">OTH</field> + <field name="l10n_sa_additional_identification_number">3101753974</field> + </record> + + <record id="partner_demo_simplified" model="res.partner"> + <field name="name">Mohammed Maamour</field> + <field name="street">Al Amir Mohammed Bin Abdul Aziz Street</field> + <field name="city">المدينة المنورة</field> + <field name="country_id" ref="base.sa"/> + <field name="state_id" ref="base.state_sa_70"/> + <field name="zip">42318</field> + <field name="phone">+966 55 777 8888</field> + <field name="email">info@company.saexample.com</field> + <field name="website">www.saexample.com</field> + <field name="l10n_sa_additional_identification_number">123456789</field> + </record> + + <record id="partner_demo_standard" model="res.partner"> + <field name="name">ARAMCO Medinah Branch</field> + <field name="street">Al Amir Mohammed Bin Abdul Aziz Street</field> + <field name="street2">Ammi Saysi</field> + <field name="city">المدينة المنورة</field> + <field name="country_id" ref="base.sa"/> + <field name="state_id" ref="base.state_sa_70"/> + <field name="zip">42317</field> + <field name="vat">311112111111113</field> + <field name="company_type">company</field> + <field name="phone">+966 55 999 1010</field> + <field name="email">info@company.saexample.com</field> + <field name="website">www.saexample.com</field> + <field name="l10n_sa_edi_building_number">1234</field> + <field name="l10n_sa_edi_plot_identification">1234</field> + <field name="l10n_sa_additional_identification_number">123456789</field> + </record> + + <function model="account.journal" name="_l10n_sa_load_edi_demo_data"> + <value model="account.journal" + eval="obj().search([ + ('type', '=', 'sale'), + ('company_id', '=', ref('l10n_sa.demo_company_sa'))], limit=1).ids"/> + </function> + +</odoo> diff --git a/addons/l10n_sa_edi/models/__init__.py b/addons/l10n_sa_edi/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..47185ed0ba3a29457f12c61ff026c92d3c4407a5 --- /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 0000000000000000000000000000000000000000..9858f394861c0e651fb182a4d58825b2343f3b02 --- /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 0000000000000000000000000000000000000000..4b666e016a38ab28d1f6f366c5431176aea3f5c1 --- /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 0000000000000000000000000000000000000000..f435876d907d97f6d00409864632b2f2884b46c6 --- /dev/null +++ b/addons/l10n_sa_edi/models/account_edi_xml_ubl_21_zatca.py @@ -0,0 +1,410 @@ +# -*- 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 """ + res = super()._get_delivery_vals_list(invoice) + if 'partner_shipping_id' in invoice._fields: + for vals in res: + vals['actual_delivery_date'] = invoice.l10n_sa_delivery_date + return res + + 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 0000000000000000000000000000000000000000..e7ca06eb4676d3fb2f46f2cb2346d0f641c046b1 --- /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("<p class='mb-0'>%s <b>%s</b></p>") % (_("Could not complete Compliance Checks for the following file:"), fname)) + if result['validationResults']['status'] == 'WARNING': + warnings = "".join(Markup("<li><b>%s</b>: %s </li>") % (e['code'], e['message']) for e in result['validationResults']['warningMessages']) + self.l10n_sa_csr_errors = Markup("<br/><br/><ul class='pl-3'><b>%s</b>%s</ul>") % (_("Warnings:"), warnings) + elif result['validationResults']['status'] != 'PASS': + errors = "".join(Markup("<li><b>%s</b>: %s </li>") % (e['code'], e['message']) for e in result['validationResults']['errorMessages']) + raise UserError(Markup("<p class='mb-0'>%s <b>%s</b> %s</p>") + % (_("Could not complete Compliance Checks for the following file:"), fname, Markup("<br/><br/><ul class='pl-3'><b>%s</b>%s</ul>") % (_("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 0000000000000000000000000000000000000000..45455adf8df1d94e59d93f83ef49fc02050869f0 --- /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(""" + <p class='mb-0'> + %s + </p> + <hr> + <p class='mb-0'> + %s + </p> + """) % (_('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(""" + <p class='mb-0'> + %s + </p> + <hr> + <p class='mb-0'> + %s + </p> + """) % (_('The invoice was accepted by ZATCA, but returned warnings. Please, check the response below:'), "<br/>".join([Markup("<b>%s</b> : %s") % (m['code'], m['message']) for m in response_data['validationResults']['warningMessages']])) + self.message_post(body=Markup(""" + <div role='alert' class='alert alert-%s'> + <h4 class='alert-heading'>%s</h4>%s + </div> + """) % (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 0000000000000000000000000000000000000000..665d54f8a8baf974634e64229da211910bb8b4cc --- /dev/null +++ b/addons/l10n_sa_edi/models/account_tax.py @@ -0,0 +1,58 @@ +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")) + + +class AccountTaxTemplate(models.Model): + _inherit = 'account.tax.template' + + 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)") + + def _get_tax_vals(self, company, tax_template_to_tax): + # OVERRIDE + res = super()._get_tax_vals(company, tax_template_to_tax) + res['l10n_sa_is_retention'] = self.l10n_sa_is_retention + res['l10n_sa_exemption_reason_code'] = self.l10n_sa_exemption_reason_code + return res 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 0000000000000000000000000000000000000000..e5db7e0a7529721a4b0ab3f850690f4389e43d93 --- /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 0000000000000000000000000000000000000000..64ed9e43959aa74eeac09c7d96bd258b5c522e13 --- /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 0000000000000000000000000000000000000000..c49d5bda4b1f08da2422e20b0d3aca3fd1688108 --- /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 0000000000000000000000000000000000000000..58c6aabd2c872de200430479585c37fbb2f07942 --- /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 0000000000000000000000000000000000000000..dcaad578cb08fc9a0d16e605ab6913c5f0c8acfd --- /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 0000000000000000000000000000000000000000..f64672ea7671b8b2dcb7512f0a91838c5fd7fcd4 --- /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 0000000000000000000000000000000000000000..2eb016509d4342509927b06fdd055b1844938d58 --- /dev/null +++ b/addons/l10n_sa_edi/tests/common.py @@ -0,0 +1,245 @@ +# 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='l10n_sa.sa_chart_template_standard', 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), ('name', '=', 'Customer Invoices')]) + 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), ('name', '=', 'Sales Tax 15%')]) + + # 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 = '''<xpath expr="//*[local-name()='UBLExtensions']" position="replace"/>''' + + cls.invoice_applied_xpath = ''' + <xpath expr="(//*[local-name()='Invoice']/*[local-name()='ID'])[1]" position="replace"> + <ID>___ignore___</ID> + </xpath> + <xpath expr="(//*[local-name()='Invoice']/*[local-name()='UUID'])[1]" position="replace"> + <UUID>___ignore___</UUID> + </xpath> + <xpath expr="(//*[local-name()='Contact']/*[local-name()='ID'])[1]" position="replace"> + <ID>___ignore___</ID> + </xpath> + <xpath expr="(//*[local-name()='Contact']/*[local-name()='ID'])[2]" position="replace"> + <ID>___ignore___</ID> + </xpath> + <xpath expr="//*[local-name()='PaymentMeans']/*[local-name()='InstructionID']" position="replace"> + <InstructionID>___ignore___</InstructionID> + </xpath> + <xpath expr="(//*[local-name()='PaymentMeans']/*[local-name()='PaymentID'])" position="replace"> + <PaymentID>___ignore___</PaymentID> + </xpath> + <xpath expr="//*[local-name()='InvoiceLine']/*[local-name()='ID']" position="replace"> + <ID>___ignore___</ID> + </xpath> + ''' + + cls.credit_note_applied_xpath = ''' + <xpath expr="(//*[local-name()='Invoice']/*[local-name()='ID'])[1]" position="replace"> + <ID>___ignore___</ID> + </xpath> + <xpath expr="(//*[local-name()='Invoice']/*[local-name()='UUID'])[1]" position="replace"> + <UUID>___ignore___</UUID> + </xpath> + <xpath expr="(//*[local-name()='Contact']/*[local-name()='ID'])[1]" position="replace"> + <ID>___ignore___</ID> + </xpath> + <xpath expr="(//*[local-name()='Contact']/*[local-name()='ID'])[2]" position="replace"> + <ID>___ignore___</ID> + </xpath> + <xpath expr="(//*[local-name()='OrderReference']/*[local-name()='ID'])[1]" position="replace"> + <ID>___ignore___</ID> + </xpath> + <xpath expr="(//*[local-name()='InvoiceDocumentReference']/*[local-name()='ID'])[1]" position="replace"> + <ID>___ignore___</ID> + </xpath> + <xpath expr="(//*[local-name()='PaymentMeans']/*[local-name()='InstructionNote'])" position="replace"> + <InstructionNote>___ignore___</InstructionNote> + </xpath> + <xpath expr="(//*[local-name()='PaymentMeans']/*[local-name()='PaymentID'])" position="replace"> + <PaymentID>___ignore___</PaymentID> + </xpath> + <xpath expr="//*[local-name()='InvoiceLine']/*[local-name()='ID']" position="replace"> + <ID>___ignore___</ID> + </xpath> + ''' + + cls.debit_note_applied_xpath = ''' + <xpath expr="(//*[local-name()='Invoice']/*[local-name()='ID'])[1]" position="replace"> + <ID>___ignore___</ID> + </xpath> + <xpath expr="(//*[local-name()='Invoice']/*[local-name()='UUID'])[1]" position="replace"> + <UUID>___ignore___</UUID> + </xpath> + <xpath expr="(//*[local-name()='Contact']/*[local-name()='ID'])[1]" position="replace"> + <ID>___ignore___</ID> + </xpath> + <xpath expr="(//*[local-name()='Contact']/*[local-name()='ID'])[2]" position="replace"> + <ID>___ignore___</ID> + </xpath> + <xpath expr="(//*[local-name()='OrderReference']/*[local-name()='ID'])[1]" position="replace"> + <ID>___ignore___</ID> + </xpath> + <xpath expr="(//*[local-name()='InvoiceDocumentReference']/*[local-name()='ID'])[1]" position="replace"> + <ID>___ignore___</ID> + </xpath> + <xpath expr="//*[local-name()='InvoiceLine']/*[local-name()='ID']" position="replace"> + <ID>___ignore___</ID> + </xpath> + <xpath expr="//*[local-name()='PaymentMeans']/*[local-name()='InstructionID']" position="replace"> + <InstructionID>___ignore___</InstructionID> + </xpath> + <xpath expr="(//*[local-name()='PaymentMeans']/*[local-name()='PaymentID'])" position="replace"> + <PaymentID>___ignore___</PaymentID> + </xpath> + <xpath expr="(//*[local-name()='PaymentMeans']/*[local-name()='InstructionNote'])" position="replace"> + <InstructionNote>___ignore___</InstructionNote> + </xpath> + ''' + + def _create_invoice(self, **kwargs): + vals = { + 'name': kwargs['name'], + 'move_type': 'out_invoice', + 'company_id': self.company, + 'partner_id': kwargs['partner_id'], + 'invoice_date': kwargs['date'], + 'invoice_date_due': kwargs['date_due'], + 'currency_id': self.company.currency_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', + 'refund_method': 'refund', + '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 0000000000000000000000000000000000000000..5458e9d9585687768d1cf80b9845a628c4698718 --- /dev/null +++ b/addons/l10n_sa_edi/tests/compliance/simplified/credit.xml @@ -0,0 +1,225 @@ +<Invoice xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2"> + <cbc:UBLVersionID>2.1</cbc:UBLVersionID> + <cbc:ProfileID>reporting:1.0</cbc:ProfileID> + <cbc:ID>RINV/2023/00005</cbc:ID> + <cbc:UUID>790b6c13-72c1-4dac-9f23-6e9d3b43e151</cbc:UUID> + <cbc:IssueDate>2023-03-10</cbc:IssueDate> + <cbc:IssueTime>14:59:38</cbc:IssueTime> + <cbc:InvoiceTypeCode name="0200000">381</cbc:InvoiceTypeCode> + <cbc:DocumentCurrencyCode>SAR</cbc:DocumentCurrencyCode> + <cbc:TaxCurrencyCode>SAR</cbc:TaxCurrencyCode> + <cbc:BuyerReference>Mohammed Ali</cbc:BuyerReference> + <cac:OrderReference> + <cbc:ID>Test</cbc:ID> + </cac:OrderReference> + <cac:BillingReference> + <cac:InvoiceDocumentReference> + <cbc:ID>INV/2023/00034</cbc:ID> + </cac:InvoiceDocumentReference> + </cac:BillingReference> + <cac:AdditionalDocumentReference> + <cbc:ID>QR</cbc:ID> + <cac:Attachment> + <cbc:EmbeddedDocumentBinaryObject mimeCode="text/plain">N/A</cbc:EmbeddedDocumentBinaryObject> + </cac:Attachment> + </cac:AdditionalDocumentReference> + <cac:AdditionalDocumentReference> + <cbc:ID>PIH</cbc:ID> + <cac:Attachment> + <cbc:EmbeddedDocumentBinaryObject mimeCode="text/plain">NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==</cbc:EmbeddedDocumentBinaryObject> + </cac:Attachment> + </cac:AdditionalDocumentReference> + <cac:AdditionalDocumentReference> + <cbc:ID>ICV</cbc:ID> + <cbc:UUID>0</cbc:UUID> + </cac:AdditionalDocumentReference> + <cac:Signature> + <cbc:ID>urn:oasis:names:specification:ubl:signature:Invoice</cbc:ID> + <cbc:SignatureMethod>urn:oasis:names:specification:ubl:dsig:enveloped:xades</cbc:SignatureMethod> + </cac:Signature> + <cac:AccountingSupplierParty> + <cac:Party> + <cac:PartyIdentification> + <cbc:ID schemeID="CRN">2525252525252</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>SA Company Test</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:PostalAddress> + <cac:PartyTaxScheme> + <cbc:RegistrationName>SA Company Test</cbc:RegistrationName> + <cbc:CompanyID>311111111111113</cbc:CompanyID> + <cac:RegistrationAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:PartyTaxScheme> + <cac:PartyLegalEntity> + <cbc:RegistrationName>SA Company Test</cbc:RegistrationName> + <cbc:CompanyID>311111111111113</cbc:CompanyID> + <cac:RegistrationAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + </cac:PartyLegalEntity> + <cac:Contact> + <cbc:ID>1</cbc:ID> + <cbc:Name>SA Company Test</cbc:Name> + <cbc:Telephone>+966 51 234 5678</cbc:Telephone> + <cbc:ElectronicMail>info@company.saexample.com</cbc:ElectronicMail> + </cac:Contact> + </cac:Party> + </cac:AccountingSupplierParty> + <cac:AccountingCustomerParty> + <cac:Party> + <cac:PartyIdentification> + <cbc:ID schemeID="MOM">3123123213131</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>Mohammed Ali</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:PostalAddress> + <cac:PartyTaxScheme> + <cbc:RegistrationName>Mohammed Ali</cbc:RegistrationName> + <cac:RegistrationAddress> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:PartyTaxScheme> + <cac:PartyLegalEntity> + <cbc:RegistrationName>Mohammed Ali</cbc:RegistrationName> + <cac:RegistrationAddress> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + </cac:PartyLegalEntity> + <cac:Contact> + <cbc:ID>7</cbc:ID> + <cbc:Name>Mohammed Ali</cbc:Name> + </cac:Contact> + </cac:Party> + </cac:AccountingCustomerParty> + <cac:Delivery> + <cbc:ActualDeliveryDate>2023-03-10</cbc:ActualDeliveryDate> + </cac:Delivery> + <cac:PaymentMeans> + <cbc:PaymentMeansCode listID="UN/ECE 4461">1</cbc:PaymentMeansCode> + <cbc:PaymentDueDate>2023-03-10</cbc:PaymentDueDate> + <cbc:InstructionNote>Accounting Mistake</cbc:InstructionNote> + <cbc:PaymentID>RINV/2023/00005</cbc:PaymentID> + </cac:PaymentMeans> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">119.25</cbc:TaxAmount> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="SAR">795.00</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="SAR">119.25</cbc:TaxAmount> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + </cac:TaxTotal> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">119.25</cbc:TaxAmount> + </cac:TaxTotal> + <cac:LegalMonetaryTotal> + <cbc:LineExtensionAmount currencyID="SAR">795.00</cbc:LineExtensionAmount> + <cbc:TaxExclusiveAmount currencyID="SAR">795.00</cbc:TaxExclusiveAmount> + <cbc:TaxInclusiveAmount currencyID="SAR">914.25</cbc:TaxInclusiveAmount> + <cbc:PrepaidAmount currencyID="SAR">0.00</cbc:PrepaidAmount> + <cbc:PayableAmount currencyID="SAR">914.25</cbc:PayableAmount> + </cac:LegalMonetaryTotal> + <cac:InvoiceLine> + <cbc:ID>167</cbc:ID> + <cbc:InvoicedQuantity unitCode="C62">3.0</cbc:InvoicedQuantity> + <cbc:LineExtensionAmount currencyID="SAR">795.00</cbc:LineExtensionAmount> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">119.25</cbc:TaxAmount> + <cbc:RoundingAmount currencyID="SAR">914.25</cbc:RoundingAmount> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="SAR">795.00</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="SAR">119.25</cbc:TaxAmount> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + </cac:TaxTotal> + <cac:Item> + <cbc:Description>Burger</cbc:Description> + <cbc:Name>Burger</cbc:Name> + <cac:ClassifiedTaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:ClassifiedTaxCategory> + </cac:Item> + <cac:Price> + <cbc:PriceAmount currencyID="SAR">265.00</cbc:PriceAmount> + </cac:Price> + </cac:InvoiceLine> +</Invoice> 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 0000000000000000000000000000000000000000..ae57f6ea68abfe98c3070a55f198d94140bce4db --- /dev/null +++ b/addons/l10n_sa_edi/tests/compliance/simplified/debit.xml @@ -0,0 +1,226 @@ +<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2"> + <cbc:UBLVersionID>2.1</cbc:UBLVersionID> + <cbc:ProfileID>reporting:1.0</cbc:ProfileID> + <cbc:ID>INV/2023/00035</cbc:ID> + <cbc:UUID>47fdc8c6-2346-460a-8231-c28bf3bab44c</cbc:UUID> + <cbc:IssueDate>2023-03-10</cbc:IssueDate> + <cbc:IssueTime>15:01:46</cbc:IssueTime> + <cbc:InvoiceTypeCode name="0200000">383</cbc:InvoiceTypeCode> + <cbc:DocumentCurrencyCode>SAR</cbc:DocumentCurrencyCode> + <cbc:TaxCurrencyCode>SAR</cbc:TaxCurrencyCode> + <cbc:BuyerReference>Mohammed Ali</cbc:BuyerReference> + <cac:OrderReference> + <cbc:ID>Test</cbc:ID> + </cac:OrderReference> + <cac:BillingReference> + <cac:InvoiceDocumentReference> + <cbc:ID>INV/2023/00034</cbc:ID> + </cac:InvoiceDocumentReference> + </cac:BillingReference> + <cac:AdditionalDocumentReference> + <cbc:ID>QR</cbc:ID> + <cac:Attachment> + <cbc:EmbeddedDocumentBinaryObject mimeCode="text/plain">N/A</cbc:EmbeddedDocumentBinaryObject> + </cac:Attachment> + </cac:AdditionalDocumentReference> + <cac:AdditionalDocumentReference> + <cbc:ID>PIH</cbc:ID> + <cac:Attachment> + <cbc:EmbeddedDocumentBinaryObject mimeCode="text/plain">NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==</cbc:EmbeddedDocumentBinaryObject> + </cac:Attachment> + </cac:AdditionalDocumentReference> + <cac:AdditionalDocumentReference> + <cbc:ID>ICV</cbc:ID> + <cbc:UUID>0</cbc:UUID> + </cac:AdditionalDocumentReference> + <cac:Signature> + <cbc:ID>urn:oasis:names:specification:ubl:signature:Invoice</cbc:ID> + <cbc:SignatureMethod>urn:oasis:names:specification:ubl:dsig:enveloped:xades</cbc:SignatureMethod> + </cac:Signature> + <cac:AccountingSupplierParty> + <cac:Party> + <cac:PartyIdentification> + <cbc:ID schemeID="CRN">2525252525252</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>SA Company Test</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:PostalAddress> + <cac:PartyTaxScheme> + <cbc:RegistrationName>SA Company Test</cbc:RegistrationName> + <cbc:CompanyID>311111111111113</cbc:CompanyID> + <cac:RegistrationAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:PartyTaxScheme> + <cac:PartyLegalEntity> + <cbc:RegistrationName>SA Company Test</cbc:RegistrationName> + <cbc:CompanyID>311111111111113</cbc:CompanyID> + <cac:RegistrationAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + </cac:PartyLegalEntity> + <cac:Contact> + <cbc:ID>1</cbc:ID> + <cbc:Name>SA Company Test</cbc:Name> + <cbc:Telephone>+966 51 234 5678</cbc:Telephone> + <cbc:ElectronicMail>info@company.saexample.com</cbc:ElectronicMail> + </cac:Contact> + </cac:Party> + </cac:AccountingSupplierParty> + <cac:AccountingCustomerParty> + <cac:Party> + <cac:PartyIdentification> + <cbc:ID schemeID="MOM">3123123213131</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>Mohammed Ali</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:PostalAddress> + <cac:PartyTaxScheme> + <cbc:RegistrationName>Mohammed Ali</cbc:RegistrationName> + <cac:RegistrationAddress> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:PartyTaxScheme> + <cac:PartyLegalEntity> + <cbc:RegistrationName>Mohammed Ali</cbc:RegistrationName> + <cac:RegistrationAddress> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + </cac:PartyLegalEntity> + <cac:Contact> + <cbc:ID>7</cbc:ID> + <cbc:Name>Mohammed Ali</cbc:Name> + </cac:Contact> + </cac:Party> + </cac:AccountingCustomerParty> + <cac:Delivery> + <cbc:ActualDeliveryDate>2023-03-10</cbc:ActualDeliveryDate> + </cac:Delivery> + <cac:PaymentMeans> + <cbc:PaymentMeansCode listID="UN/ECE 4461">1</cbc:PaymentMeansCode> + <cbc:PaymentDueDate>2023-03-10</cbc:PaymentDueDate> + <cbc:InstructionID>INV/2023/00035</cbc:InstructionID> + <cbc:InstructionNote>More Burgers</cbc:InstructionNote> + <cbc:PaymentID>INV/2023/00035</cbc:PaymentID> + </cac:PaymentMeans> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">79.50</cbc:TaxAmount> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="SAR">530.00</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="SAR">79.50</cbc:TaxAmount> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + </cac:TaxTotal> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">79.50</cbc:TaxAmount> + </cac:TaxTotal> + <cac:LegalMonetaryTotal> + <cbc:LineExtensionAmount currencyID="SAR">530.00</cbc:LineExtensionAmount> + <cbc:TaxExclusiveAmount currencyID="SAR">530.00</cbc:TaxExclusiveAmount> + <cbc:TaxInclusiveAmount currencyID="SAR">609.50</cbc:TaxInclusiveAmount> + <cbc:PrepaidAmount currencyID="SAR">0.00</cbc:PrepaidAmount> + <cbc:PayableAmount currencyID="SAR">609.50</cbc:PayableAmount> + </cac:LegalMonetaryTotal> + <cac:InvoiceLine> + <cbc:ID>170</cbc:ID> + <cbc:InvoicedQuantity unitCode="C62">2.0</cbc:InvoicedQuantity> + <cbc:LineExtensionAmount currencyID="SAR">530.00</cbc:LineExtensionAmount> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">79.50</cbc:TaxAmount> + <cbc:RoundingAmount currencyID="SAR">609.50</cbc:RoundingAmount> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="SAR">530.00</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="SAR">79.50</cbc:TaxAmount> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + </cac:TaxTotal> + <cac:Item> + <cbc:Description>Burger</cbc:Description> + <cbc:Name>Burger</cbc:Name> + <cac:ClassifiedTaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:ClassifiedTaxCategory> + </cac:Item> + <cac:Price> + <cbc:PriceAmount currencyID="SAR">265.00</cbc:PriceAmount> + </cac:Price> + </cac:InvoiceLine> +</Invoice> 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 0000000000000000000000000000000000000000..4a0ed4d07cad5fa79620386433a6abbe678bc71e --- /dev/null +++ b/addons/l10n_sa_edi/tests/compliance/simplified/invoice.xml @@ -0,0 +1,218 @@ +<Invoice xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2"> + <cbc:UBLVersionID>2.1</cbc:UBLVersionID> + <cbc:ProfileID>reporting:1.0</cbc:ProfileID> + <cbc:ID>INV/2023/00034</cbc:ID> + <cbc:UUID>22b851e5-6fd9-47af-807e-78ef1526667d</cbc:UUID> + <cbc:IssueDate>2023-03-10</cbc:IssueDate> + <cbc:IssueTime>14:56:55</cbc:IssueTime> + <cbc:InvoiceTypeCode name="0200000">388</cbc:InvoiceTypeCode> + <cbc:DocumentCurrencyCode>SAR</cbc:DocumentCurrencyCode> + <cbc:TaxCurrencyCode>SAR</cbc:TaxCurrencyCode> + <cbc:BuyerReference>Mohammed Ali</cbc:BuyerReference> + <cac:AdditionalDocumentReference> + <cbc:ID>QR</cbc:ID> + <cac:Attachment> + <cbc:EmbeddedDocumentBinaryObject mimeCode="text/plain">N/A</cbc:EmbeddedDocumentBinaryObject> + </cac:Attachment> + </cac:AdditionalDocumentReference> + <cac:AdditionalDocumentReference> + <cbc:ID>PIH</cbc:ID> + <cac:Attachment> + <cbc:EmbeddedDocumentBinaryObject mimeCode="text/plain">NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==</cbc:EmbeddedDocumentBinaryObject> + </cac:Attachment> + </cac:AdditionalDocumentReference> + <cac:AdditionalDocumentReference> + <cbc:ID>ICV</cbc:ID> + <cbc:UUID>0</cbc:UUID> + </cac:AdditionalDocumentReference> + <cac:Signature> + <cbc:ID>urn:oasis:names:specification:ubl:signature:Invoice</cbc:ID> + <cbc:SignatureMethod>urn:oasis:names:specification:ubl:dsig:enveloped:xades</cbc:SignatureMethod> + </cac:Signature> + <cac:AccountingSupplierParty> + <cac:Party> + <cac:PartyIdentification> + <cbc:ID schemeID="CRN">2525252525252</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>SA Company Test</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:PostalAddress> + <cac:PartyTaxScheme> + <cbc:RegistrationName>SA Company Test</cbc:RegistrationName> + <cbc:CompanyID>311111111111113</cbc:CompanyID> + <cac:RegistrationAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:PartyTaxScheme> + <cac:PartyLegalEntity> + <cbc:RegistrationName>SA Company Test</cbc:RegistrationName> + <cbc:CompanyID>311111111111113</cbc:CompanyID> + <cac:RegistrationAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + </cac:PartyLegalEntity> + <cac:Contact> + <cbc:ID>1</cbc:ID> + <cbc:Name>SA Company Test</cbc:Name> + <cbc:Telephone>+966 51 234 5678</cbc:Telephone> + <cbc:ElectronicMail>info@company.saexample.com</cbc:ElectronicMail> + </cac:Contact> + </cac:Party> + </cac:AccountingSupplierParty> + <cac:AccountingCustomerParty> + <cac:Party> + <cac:PartyIdentification> + <cbc:ID schemeID="MOM">3123123213131</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>Mohammed Ali</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:PostalAddress> + <cac:PartyTaxScheme> + <cbc:RegistrationName>Mohammed Ali</cbc:RegistrationName> + <cac:RegistrationAddress> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:PartyTaxScheme> + <cac:PartyLegalEntity> + <cbc:RegistrationName>Mohammed Ali</cbc:RegistrationName> + <cac:RegistrationAddress> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + </cac:PartyLegalEntity> + <cac:Contact> + <cbc:ID>7</cbc:ID> + <cbc:Name>Mohammed Ali</cbc:Name> + </cac:Contact> + </cac:Party> + </cac:AccountingCustomerParty> + <cac:Delivery> + <cbc:ActualDeliveryDate>2023-03-10</cbc:ActualDeliveryDate> + </cac:Delivery> + <cac:PaymentMeans> + <cbc:PaymentMeansCode listID="UN/ECE 4461">1</cbc:PaymentMeansCode> + <cbc:PaymentDueDate>2023-03-10</cbc:PaymentDueDate> + <cbc:InstructionID>INV/2023/00034</cbc:InstructionID> + <cbc:PaymentID>INV/2023/00034</cbc:PaymentID> + </cac:PaymentMeans> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">119.25</cbc:TaxAmount> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="SAR">795.00</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="SAR">119.25</cbc:TaxAmount> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + </cac:TaxTotal> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">119.25</cbc:TaxAmount> + </cac:TaxTotal> + <cac:LegalMonetaryTotal> + <cbc:LineExtensionAmount currencyID="SAR">795.00</cbc:LineExtensionAmount> + <cbc:TaxExclusiveAmount currencyID="SAR">795.00</cbc:TaxExclusiveAmount> + <cbc:TaxInclusiveAmount currencyID="SAR">914.25</cbc:TaxInclusiveAmount> + <cbc:PrepaidAmount currencyID="SAR">0.00</cbc:PrepaidAmount> + <cbc:PayableAmount currencyID="SAR">914.25</cbc:PayableAmount> + </cac:LegalMonetaryTotal> + <cac:InvoiceLine> + <cbc:ID>164</cbc:ID> + <cbc:InvoicedQuantity unitCode="C62">3.0</cbc:InvoicedQuantity> + <cbc:LineExtensionAmount currencyID="SAR">795.00</cbc:LineExtensionAmount> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">119.25</cbc:TaxAmount> + <cbc:RoundingAmount currencyID="SAR">914.25</cbc:RoundingAmount> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="SAR">795.00</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="SAR">119.25</cbc:TaxAmount> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + </cac:TaxTotal> + <cac:Item> + <cbc:Description>Burger</cbc:Description> + <cbc:Name>Burger</cbc:Name> + <cac:ClassifiedTaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:ClassifiedTaxCategory> + </cac:Item> + <cac:Price> + <cbc:PriceAmount currencyID="SAR">265.00</cbc:PriceAmount> + </cac:Price> + </cac:InvoiceLine> + +</Invoice> 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 0000000000000000000000000000000000000000..d1c023fe450ca9591034eb4c23305828e2588c30 --- /dev/null +++ b/addons/l10n_sa_edi/tests/compliance/standard/credit.xml @@ -0,0 +1,222 @@ +<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" + xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" + xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2"> + <cbc:UBLVersionID>2.1</cbc:UBLVersionID> + <cbc:ProfileID>reporting:1.0</cbc:ProfileID> + <cbc:ID>RINV/2022/00006</cbc:ID> + <cbc:UUID>6c49b8e0-2ce5-11ed-b6c7-c54ae37ec60b</cbc:UUID> + <cbc:IssueDate>2022-09-05</cbc:IssueDate> + <cbc:IssueTime>09:39:15</cbc:IssueTime> + <cbc:InvoiceTypeCode name="0100100">381</cbc:InvoiceTypeCode> + <cbc:DocumentCurrencyCode>SAR</cbc:DocumentCurrencyCode> + <cbc:TaxCurrencyCode>SAR</cbc:TaxCurrencyCode> + <cbc:BuyerReference>Azure Interior</cbc:BuyerReference> + <cac:OrderReference> + <cbc:ID>test</cbc:ID> + </cac:OrderReference> + <cac:BillingReference> + <cac:InvoiceDocumentReference> + <cbc:ID>test</cbc:ID> + </cac:InvoiceDocumentReference> + </cac:BillingReference> + <cac:AdditionalDocumentReference> + <cbc:ID>PIH</cbc:ID> + <cac:Attachment> + <cbc:EmbeddedDocumentBinaryObject mimeCode="text/plain"> + NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==</cbc:EmbeddedDocumentBinaryObject> + </cac:Attachment> + </cac:AdditionalDocumentReference> + <cac:AdditionalDocumentReference> + <cbc:ID>ICV</cbc:ID> + <cbc:UUID>137</cbc:UUID> + </cac:AdditionalDocumentReference> + <cac:AccountingSupplierParty> + <cac:Party> + <cac:PartyIdentification> + <cbc:ID schemeID="CRN">2525252525252</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>SA Company Test</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:PostalAddress> + <cac:PartyTaxScheme> + <cbc:RegistrationName>SA Company Test</cbc:RegistrationName> + <cbc:CompanyID>311111111111113</cbc:CompanyID> + <cac:RegistrationAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:PartyTaxScheme> + <cac:PartyLegalEntity> + <cbc:RegistrationName>SA Company Test</cbc:RegistrationName> + <cbc:CompanyID>311111111111113</cbc:CompanyID> + <cac:RegistrationAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + </cac:PartyLegalEntity> + <cac:Contact> + <cbc:ID>337</cbc:ID> + <cbc:Name>SA Company Test</cbc:Name> + <cbc:Telephone>+966 51 234 5678</cbc:Telephone> + <cbc:ElectronicMail>info@company.saexample.com</cbc:ElectronicMail> + </cac:Contact> + </cac:Party> + </cac:AccountingSupplierParty> + <cac:AccountingCustomerParty> + <cac:Party> + <cac:PartyIdentification> + <cbc:ID schemeID="CRN">353535353535353</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>Chichi Lboukla</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:StreetName>4557 De Silva St</cbc:StreetName> + <cbc:BuildingNumber>12300</cbc:BuildingNumber> + <cbc:PlotIdentification>2323</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName> + <cbc:CityName>Fremont</cbc:CityName> + <cbc:PostalZone>94538</cbc:PostalZone> + <cbc:CountrySubentity>California</cbc:CountrySubentity> + <cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>US</cbc:IdentificationCode> + <cbc:Name>United States</cbc:Name> + </cac:Country> + </cac:PostalAddress> + <cac:PartyLegalEntity> + <cbc:RegistrationName>Chichi Lboukla</cbc:RegistrationName> + <cac:RegistrationAddress> + <cbc:StreetName>4557 De Silva St</cbc:StreetName> + <cbc:BuildingNumber>12300</cbc:BuildingNumber> + <cbc:PlotIdentification>2323</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName> + <cbc:CityName>Fremont</cbc:CityName> + <cbc:PostalZone>94538</cbc:PostalZone> + <cbc:CountrySubentity>California</cbc:CountrySubentity> + <cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>US</cbc:IdentificationCode> + <cbc:Name>United States</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + </cac:PartyLegalEntity> + <cac:Contact> + <cbc:ID>340</cbc:ID> + <cbc:Name>Chichi Lboukla</cbc:Name> + <cbc:Telephone>(870)-931-0505</cbc:Telephone> + <cbc:ElectronicMail>azure.Interior24@example.com</cbc:ElectronicMail> + </cac:Contact> + </cac:Party> + </cac:AccountingCustomerParty> + <cac:Delivery> + <cbc:ActualDeliveryDate>2022-09-05</cbc:ActualDeliveryDate> + </cac:Delivery> + <cac:PaymentMeans> + <cbc:PaymentMeansCode listID="UN/ECE 4461">1</cbc:PaymentMeansCode> + <cbc:PaymentDueDate>2022-09-05</cbc:PaymentDueDate> + <cbc:InstructionNote>Ttest</cbc:InstructionNote> + <cbc:PaymentID>RINV/2022/00002</cbc:PaymentID> + </cac:PaymentMeans> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">48.00</cbc:TaxAmount> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="SAR">320.00</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="SAR">48.00</cbc:TaxAmount> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + </cac:TaxTotal> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">48.00</cbc:TaxAmount> + </cac:TaxTotal> + <cac:LegalMonetaryTotal> + <cbc:LineExtensionAmount currencyID="SAR">320.00</cbc:LineExtensionAmount> + <cbc:TaxExclusiveAmount currencyID="SAR">320.00</cbc:TaxExclusiveAmount> + <cbc:TaxInclusiveAmount currencyID="SAR">368.00</cbc:TaxInclusiveAmount> + <cbc:PrepaidAmount currencyID="SAR">0.00</cbc:PrepaidAmount> + <cbc:PayableAmount currencyID="SAR">368.00</cbc:PayableAmount> + </cac:LegalMonetaryTotal> + <cac:InvoiceLine> + <cbc:ID>390</cbc:ID> + <cbc:InvoicedQuantity unitCode="C62">1.0</cbc:InvoicedQuantity> + <cbc:LineExtensionAmount currencyID="SAR">320.00</cbc:LineExtensionAmount> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">48.00</cbc:TaxAmount> + <cbc:RoundingAmount currencyID="SAR">368.00</cbc:RoundingAmount> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="SAR">320.00</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="SAR">48.00</cbc:TaxAmount> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + </cac:TaxTotal> + <cac:Item> + <cbc:Description>[P0001] Product A</cbc:Description> + <cbc:Name>Product A</cbc:Name> + <cac:SellersItemIdentification> + <cbc:ID>P0001</cbc:ID> + </cac:SellersItemIdentification> + <cac:ClassifiedTaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:ClassifiedTaxCategory> + </cac:Item> + <cac:Price> + <cbc:PriceAmount currencyID="SAR">320.00</cbc:PriceAmount> + </cac:Price> + </cac:InvoiceLine> +</Invoice> 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 0000000000000000000000000000000000000000..5ede0d7ac44e45c0ddc0b1bf4fed75e2982cf269 --- /dev/null +++ b/addons/l10n_sa_edi/tests/compliance/standard/debit.xml @@ -0,0 +1,223 @@ +<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" + xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" + xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2"> + <cbc:UBLVersionID>2.1</cbc:UBLVersionID> + <cbc:ProfileID>reporting:1.0</cbc:ProfileID> + <cbc:ID>INV/2022/00015</cbc:ID> + <cbc:UUID>4dfa4796-2ce6-11ed-b6c7-c54ae37ec60b</cbc:UUID> + <cbc:IssueDate>2022-09-05</cbc:IssueDate> + <cbc:IssueTime>09:45:27</cbc:IssueTime> + <cbc:InvoiceTypeCode name="0100100">383</cbc:InvoiceTypeCode> + <cbc:DocumentCurrencyCode>SAR</cbc:DocumentCurrencyCode> + <cbc:TaxCurrencyCode>SAR</cbc:TaxCurrencyCode> + <cbc:BuyerReference>Azure Interior</cbc:BuyerReference> + <cac:OrderReference> + <cbc:ID>INV/2022/00014, Totes forgot</cbc:ID> + </cac:OrderReference> + <cac:BillingReference> + <cac:InvoiceDocumentReference> + <cbc:ID>INV/2022/00014</cbc:ID> + </cac:InvoiceDocumentReference> + </cac:BillingReference> + <cac:AdditionalDocumentReference> + <cbc:ID>PIH</cbc:ID> + <cac:Attachment> + <cbc:EmbeddedDocumentBinaryObject mimeCode="text/plain"> + NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==</cbc:EmbeddedDocumentBinaryObject> + </cac:Attachment> + </cac:AdditionalDocumentReference> + <cac:AdditionalDocumentReference> + <cbc:ID>ICV</cbc:ID> + <cbc:UUID>138</cbc:UUID> + </cac:AdditionalDocumentReference> + <cac:AccountingSupplierParty> + <cac:Party> + <cac:PartyIdentification> + <cbc:ID schemeID="CRN">2525252525252</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>SA Company Test</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:PostalAddress> + <cac:PartyTaxScheme> + <cbc:RegistrationName>SA Company Test</cbc:RegistrationName> + <cbc:CompanyID>311111111111113</cbc:CompanyID> + <cac:RegistrationAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:PartyTaxScheme> + <cac:PartyLegalEntity> + <cbc:RegistrationName>SA Company Test</cbc:RegistrationName> + <cbc:CompanyID>311111111111113</cbc:CompanyID> + <cac:RegistrationAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + </cac:PartyLegalEntity> + <cac:Contact> + <cbc:ID>547</cbc:ID> + <cbc:Name>SA Company Test</cbc:Name> + <cbc:Telephone>+966 51 234 5678</cbc:Telephone> + <cbc:ElectronicMail>info@company.saexample.com</cbc:ElectronicMail> + </cac:Contact> + </cac:Party> + </cac:AccountingSupplierParty> + <cac:AccountingCustomerParty> + <cac:Party> + <cac:PartyIdentification> + <cbc:ID schemeID="CRN">353535353535353</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>Chichi Lboukla</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:StreetName>4557 De Silva St</cbc:StreetName> + <cbc:BuildingNumber>12300</cbc:BuildingNumber> + <cbc:PlotIdentification>2323</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName> + <cbc:CityName>Fremont</cbc:CityName> + <cbc:PostalZone>94538</cbc:PostalZone> + <cbc:CountrySubentity>California</cbc:CountrySubentity> + <cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>US</cbc:IdentificationCode> + <cbc:Name>United States</cbc:Name> + </cac:Country> + </cac:PostalAddress> + <cac:PartyLegalEntity> + <cbc:RegistrationName>Chichi Lboukla</cbc:RegistrationName> + <cac:RegistrationAddress> + <cbc:StreetName>4557 De Silva St</cbc:StreetName> + <cbc:BuildingNumber>12300</cbc:BuildingNumber> + <cbc:PlotIdentification>2323</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName> + <cbc:CityName>Fremont</cbc:CityName> + <cbc:PostalZone>94538</cbc:PostalZone> + <cbc:CountrySubentity>California</cbc:CountrySubentity> + <cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>US</cbc:IdentificationCode> + <cbc:Name>United States</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + </cac:PartyLegalEntity> + <cac:Contact> + <cbc:ID>550</cbc:ID> + <cbc:Name>Chichi Lboukla</cbc:Name> + <cbc:Telephone>(870)-931-0505</cbc:Telephone> + <cbc:ElectronicMail>azure.Interior24@example.com</cbc:ElectronicMail> + </cac:Contact> + </cac:Party> + </cac:AccountingCustomerParty> + <cac:Delivery> + <cbc:ActualDeliveryDate>2022-09-05</cbc:ActualDeliveryDate> + </cac:Delivery> + <cac:PaymentMeans> + <cbc:PaymentMeansCode listID="UN/ECE 4461">1</cbc:PaymentMeansCode> + <cbc:PaymentDueDate>2022-09-05</cbc:PaymentDueDate> + <cbc:InstructionID>INV/2022/00015</cbc:InstructionID> + <cbc:InstructionNote>INV/2022/00015, Totes forgot</cbc:InstructionNote> + <cbc:PaymentID>INV/2022/00015</cbc:PaymentID> + </cac:PaymentMeans> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">2.37</cbc:TaxAmount> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="SAR">15.80</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="SAR">2.37</cbc:TaxAmount> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + </cac:TaxTotal> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">2.37</cbc:TaxAmount> + </cac:TaxTotal> + <cac:LegalMonetaryTotal> + <cbc:LineExtensionAmount currencyID="SAR">15.80</cbc:LineExtensionAmount> + <cbc:TaxExclusiveAmount currencyID="SAR">15.80</cbc:TaxExclusiveAmount> + <cbc:TaxInclusiveAmount currencyID="SAR">18.17</cbc:TaxInclusiveAmount> + <cbc:PrepaidAmount currencyID="SAR">0.00</cbc:PrepaidAmount> + <cbc:PayableAmount currencyID="SAR">18.17</cbc:PayableAmount> + </cac:LegalMonetaryTotal> + <cac:InvoiceLine> + <cbc:ID>393</cbc:ID> + <cbc:InvoicedQuantity unitCode="C62">1.0</cbc:InvoicedQuantity> + <cbc:LineExtensionAmount currencyID="SAR">15.80</cbc:LineExtensionAmount> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">2.37</cbc:TaxAmount> + <cbc:RoundingAmount currencyID="SAR">18.17</cbc:RoundingAmount> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="SAR">15.80</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="SAR">2.37</cbc:TaxAmount> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + </cac:TaxTotal> + <cac:Item> + <cbc:Description>[P0002] Product B</cbc:Description> + <cbc:Name>Product B</cbc:Name> + <cac:SellersItemIdentification> + <cbc:ID>P0002</cbc:ID> + </cac:SellersItemIdentification> + <cac:ClassifiedTaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:ClassifiedTaxCategory> + </cac:Item> + <cac:Price> + <cbc:PriceAmount currencyID="SAR">15.80</cbc:PriceAmount> + </cac:Price> + </cac:InvoiceLine> +</Invoice> 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 0000000000000000000000000000000000000000..a34768f15e7811ca706f841df8eb97daf0ae0433 --- /dev/null +++ b/addons/l10n_sa_edi/tests/compliance/standard/invoice.xml @@ -0,0 +1,213 @@ +<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" + xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" + xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2"> + <cbc:UBLVersionID>2.1</cbc:UBLVersionID> + <cbc:ProfileID>reporting:1.0</cbc:ProfileID> + <cbc:ID>INV/2022/00014</cbc:ID> + <cbc:UUID>ff608a28-096e-44a1-a896-cbb52212a8a3</cbc:UUID> + <cbc:IssueDate>2022-09-05</cbc:IssueDate> + <cbc:IssueTime>08:20:02</cbc:IssueTime> + <cbc:InvoiceTypeCode name="0100100">388</cbc:InvoiceTypeCode> + <cbc:DocumentCurrencyCode>SAR</cbc:DocumentCurrencyCode> + <cbc:TaxCurrencyCode>SAR</cbc:TaxCurrencyCode> + <cbc:BuyerReference>Azure Interior</cbc:BuyerReference> + <cac:AdditionalDocumentReference> + <cbc:ID>PIH</cbc:ID> + <cac:Attachment> + <cbc:EmbeddedDocumentBinaryObject mimeCode="text/plain">NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==</cbc:EmbeddedDocumentBinaryObject> + </cac:Attachment> + </cac:AdditionalDocumentReference> + <cac:AdditionalDocumentReference> + <cbc:ID>ICV</cbc:ID> + <cbc:UUID>0</cbc:UUID> + </cac:AdditionalDocumentReference> + <cac:AccountingSupplierParty> + <cac:Party> + <cac:PartyIdentification> + <cbc:ID schemeID="CRN">2525252525252</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>SA Company Test</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:PostalAddress> + <cac:PartyTaxScheme> + <cbc:RegistrationName>SA Company Test</cbc:RegistrationName> + <cbc:CompanyID>311111111111113</cbc:CompanyID> + <cac:RegistrationAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:PartyTaxScheme> + <cac:PartyLegalEntity> + <cbc:RegistrationName>SA Company Test</cbc:RegistrationName> + <cbc:CompanyID>311111111111113</cbc:CompanyID> + <cac:RegistrationAddress> + <cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName> + <cbc:BuildingNumber>1234</cbc:BuildingNumber> + <cbc:PlotIdentification>1234</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName> + <cbc:CityName>المدينة المنورة</cbc:CityName> + <cbc:PostalZone>42317</cbc:PostalZone> + <cbc:CountrySubentity>Riyadh</cbc:CountrySubentity> + <cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>SA</cbc:IdentificationCode> + <cbc:Name>Saudi Arabia</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + </cac:PartyLegalEntity> + <cac:Contact> + <cbc:ID>33</cbc:ID> + <cbc:Name>SA Company Test</cbc:Name> + <cbc:Telephone>+966 51 234 5678</cbc:Telephone> + <cbc:ElectronicMail>info@company.saexample.com</cbc:ElectronicMail> + </cac:Contact> + </cac:Party> + </cac:AccountingSupplierParty> + <cac:AccountingCustomerParty> + <cac:Party> + <cac:PartyIdentification> + <cbc:ID schemeID="CRN">353535353535353</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>Chichi Lboukla</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:StreetName>4557 De Silva St</cbc:StreetName> + <cbc:BuildingNumber>12300</cbc:BuildingNumber> + <cbc:PlotIdentification>2323</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName> + <cbc:CityName>Fremont</cbc:CityName> + <cbc:PostalZone>94538</cbc:PostalZone> + <cbc:CountrySubentity>California</cbc:CountrySubentity> + <cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>US</cbc:IdentificationCode> + <cbc:Name>United States</cbc:Name> + </cac:Country> + </cac:PostalAddress> + <cac:PartyLegalEntity> + <cbc:RegistrationName>Chichi Lboukla</cbc:RegistrationName> + <cac:RegistrationAddress> + <cbc:StreetName>4557 De Silva St</cbc:StreetName> + <cbc:BuildingNumber>12300</cbc:BuildingNumber> + <cbc:PlotIdentification>2323</cbc:PlotIdentification> + <cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName> + <cbc:CityName>Fremont</cbc:CityName> + <cbc:PostalZone>94538</cbc:PostalZone> + <cbc:CountrySubentity>California</cbc:CountrySubentity> + <cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode>US</cbc:IdentificationCode> + <cbc:Name>United States</cbc:Name> + </cac:Country> + </cac:RegistrationAddress> + </cac:PartyLegalEntity> + <cac:Contact> + <cbc:ID>42</cbc:ID> + <cbc:Name>Chichi Lboukla</cbc:Name> + <cbc:Telephone>(870)-931-0505</cbc:Telephone> + <cbc:ElectronicMail>azure.Interior24@example.com</cbc:ElectronicMail> + </cac:Contact> + </cac:Party> + </cac:AccountingCustomerParty> + <cac:Delivery> + <cbc:ActualDeliveryDate>2022-09-05</cbc:ActualDeliveryDate> + </cac:Delivery> + <cac:PaymentMeans> + <cbc:PaymentMeansCode listID="UN/ECE 4461">1</cbc:PaymentMeansCode> + <cbc:PaymentDueDate>2022-09-22</cbc:PaymentDueDate> + <cbc:InstructionID>INV/2022/00014</cbc:InstructionID> + <cbc:PaymentID>INV/2022/00014</cbc:PaymentID> + </cac:PaymentMeans> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">48.00</cbc:TaxAmount> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="SAR">320.00</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="SAR">48.00</cbc:TaxAmount> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + </cac:TaxTotal> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">48.00</cbc:TaxAmount> + </cac:TaxTotal> + <cac:LegalMonetaryTotal> + <cbc:LineExtensionAmount currencyID="SAR">320.00</cbc:LineExtensionAmount> + <cbc:TaxExclusiveAmount currencyID="SAR">320.00</cbc:TaxExclusiveAmount> + <cbc:TaxInclusiveAmount currencyID="SAR">368.00</cbc:TaxInclusiveAmount> + <cbc:PrepaidAmount currencyID="SAR">0.00</cbc:PrepaidAmount> + <cbc:PayableAmount currencyID="SAR">368.00</cbc:PayableAmount> + </cac:LegalMonetaryTotal> + <cac:InvoiceLine> + <cbc:ID>384</cbc:ID> + <cbc:InvoicedQuantity unitCode="C62">1.0</cbc:InvoicedQuantity> + <cbc:LineExtensionAmount currencyID="SAR">320.00</cbc:LineExtensionAmount> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="SAR">48.00</cbc:TaxAmount> + <cbc:RoundingAmount currencyID="SAR">368.00</cbc:RoundingAmount> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="SAR">320.00</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="SAR">48.00</cbc:TaxAmount> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + </cac:TaxTotal> + <cac:Item> + <cbc:Description>[P0001] Product A</cbc:Description> + <cbc:Name>Product A</cbc:Name> + <cac:SellersItemIdentification> + <cbc:ID>P0001</cbc:ID> + </cac:SellersItemIdentification> + <cac:ClassifiedTaxCategory> + <cbc:ID>S</cbc:ID> + <cbc:Percent>15.0</cbc:Percent> + <cac:TaxScheme> + <cbc:ID>VAT</cbc:ID> + </cac:TaxScheme> + </cac:ClassifiedTaxCategory> + </cac:Item> + <cac:Price> + <cbc:PriceAmount currencyID="SAR">320.00</cbc:PriceAmount> + </cac:Price> + </cac:InvoiceLine> +</Invoice> 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 0000000000000000000000000000000000000000..fc79a3c7e2de8d74a7a91a3e579c39133dc15f3b --- /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 + \ + ''' + <xpath expr="(//*[local-name()='AdditionalDocumentReference']/*[local-name()='UUID'])[1]" position="replace"> + <UUID>___ignore___</UUID> + </xpath> + ''' + + 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 + \ + ''' + <xpath expr="(//*[local-name()='AdditionalDocumentReference']/*[local-name()='UUID'])[1]" position="replace"> + <UUID>___ignore___</UUID> + </xpath> + ''' + + 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 0000000000000000000000000000000000000000..6cd81353c6385cff4da97549a206ba9203b71ca7 --- /dev/null +++ b/addons/l10n_sa_edi/views/account_journal_views.xml @@ -0,0 +1,94 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + + <record id="view_account_journal_form" model="ir.ui.view"> + <field name="name">account.journal.form.l10n_sa_edi</field> + <field name="model">account.journal</field> + <field name="inherit_id" ref="account.view_account_journal_form"/> + <field name="arch" type="xml"> + <xpath expr="//notebook" position="inside"> + <field name="l10n_sa_csr" invisible="1"/> + <field name="l10n_sa_compliance_csid_json" invisible="1"/> + <field name="l10n_sa_production_csid_json" invisible="1"/> + <field name="l10n_sa_compliance_checks_passed" invisible="1"/> + <page name="zatca_einvoicing" string="ZATCA" attrs="{'invisible': [('country_code', '!=', 'SA')]}"> + <group> + <group> + <field name="l10n_sa_serial_number"/> + </group> + </group> + <p> + <b> + In order to be able to submit Invoices to ZATCA, the following steps need to be completed: + </b> + <ol class="mt-2 mb-4"> + <li> + Set a Serial Number for your device + <i class="fa fa-check text-success ms-1" + attrs="{'invisible': [('l10n_sa_serial_number', '=', False)]}"/> + </li> + <li> + Request a Compliance Certificate (CCSID) + <i class="fa fa-check text-success ms-1" + attrs="{'invisible': [('l10n_sa_compliance_csid_json', '=', False)]}"/> + </li> + <li> + Complete the Compliance Checks + <i class="fa fa-check text-success ms-1" + attrs="{'invisible': [('l10n_sa_compliance_checks_passed', '=', False)]}"/> + </li> + <li> + Request a Production Certificate (PCSID) + <i class="fa fa-check text-success ms-1" + attrs="{'invisible': [('l10n_sa_production_csid_json', '=', False)]}"/> + </li> + </ol> + </p> + <div class="alert alert-info d-flex justify-content-between align-items-center" role="alert" + attrs="{'invisible':['|', ('l10n_sa_csr_errors', '!=', False), ('l10n_sa_compliance_csid_json', '!=', False)]}"> + <p class="mb-0"> + Onboard the Journal by completing each step + </p> + <button name="%(l10n_sa_edi_otp_wizard_act_window)d" type="action" icon="fa-key" + class="btn-info "> + Onboard Journal + </button> + </div> + <div class="alert alert-danger d-flex flex-column align-items-end" role="alert" + attrs="{'invisible':['|', '|', ('l10n_sa_csr_errors', '=', False), ('l10n_sa_compliance_csid_json', '!=', False), ('l10n_sa_production_csid_json', '!=', False)]}"> + <div class="w-100"> + <h4 role="alert" class="alert-heading">Journal could not be onboarded. Please make sure the Company VAT/Identification Number are correct.</h4> + <field name="l10n_sa_csr_errors" nolabel="1" readonly="1"/> + <hr/> + </div> + <button name="%(l10n_sa_edi_otp_wizard_act_window)d" type="action" icon="fa-key" + class="btn-danger"> + Onboard Journal + </button> + </div> + <div class="alert alert-info d-flex justify-content-between align-items-center" role="alert" + attrs="{'invisible':['|', ('l10n_sa_compliance_checks_passed', '=', False), ('l10n_sa_production_csid_json', '=', False)]}"> + <p class="mb-0"> + The Production certificate is valid until + <field name="l10n_sa_production_csid_validity" readonly="1" nolabel="1" + class="fw-bold"/> + </p> + <div> + <button name="%(l10n_sa_edi_otp_wizard_act_window)d" type="action" icon="fa-refresh" + class="btn-info" context="{'default_l10n_sa_renewal': True}"> + Renew Production CSID + </button> + <button name="%(l10n_sa_edi_otp_wizard_act_window)d" type="action" icon="fa-refresh" class="btn-warning ms-2" + confirm="Are you sure you wish to re-onboard the Journal?"> + Re-Onboard + </button> + </div> + </div> + </page> + </xpath> + </field> + </record> + + </data> +</odoo> \ 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 0000000000000000000000000000000000000000..ea4a5ff5d3cabb56f8c5932ac6ff80100f0d8265 --- /dev/null +++ b/addons/l10n_sa_edi/views/account_tax_views.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + + <record id="view_tax_form" model="ir.ui.view"> + <field name="name">account.tax.form.zatca</field> + <field name="model">account.tax</field> + <field name="inherit_id" ref="account.view_tax_form"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='country_id']" position="after"> + <field name="l10n_sa_is_retention" attrs="{'invisible': ['|', '|', ('type_tax_use', '!=', 'sale'), ('country_code', '!=', 'SA'), ('amount', '>=', 0)]}"/> + <field name="l10n_sa_exemption_reason_code" attrs="{'invisible': ['|', '|', ('type_tax_use', '!=', 'sale'), ('country_code', '!=', 'SA'), ('amount', '!=', 0)]}"/> + </xpath> + </field> + </record> + +</odoo> 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 0000000000000000000000000000000000000000..1c803086773e3f77263d03b9246bc953ca422e0b --- /dev/null +++ b/addons/l10n_sa_edi/views/report_invoice.xml @@ -0,0 +1,66 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + + <template id="arabic_english_invoice" inherit_id="l10n_sa.arabic_english_invoice"> + + <!-- Add Currency Exchange rate if different currency than SAR --> + <xpath expr="//div[hasclass('clearfix')]" position="after"> + <div t-if="o.company_id.country_id.code == 'SA' and o.currency_id != o.company_id.currency_id" + id="sar_amounts" class="row clearfix ms-auto my-3 text-nowrap table"> + <t t-set="sar_rate" + t-value="o.env['res.currency']._get_conversion_rate(o.currency_id, o.company_id.currency_id, o.company_id, o.l10n_sa_confirmation_datetime)"/> + <div name="exchange_rate" class="col-auto"> + <strong>Exchange Rate</strong> + <p class="m-0" t-esc="sar_rate" t-options='{"widget": "float", "precision": 5}'/> + </div> + <div name="sar_subtotal" class="col-auto"> + <strong>Subtotal (SAR)</strong> + <p class="m-0" t-esc="o.amount_untaxed_signed" + t-options='{"widget": "monetary", "display_currency": o.company_currency_id}'/> + </div> + <div name="sar_vat_amount" class="col-auto"> + <strong>VAT Amount (SAR)</strong> + <p class="m-0" + t-esc="o.currency_id._convert(o.amount_tax, o.company_id.currency_id, o.company_id, o.l10n_sa_confirmation_datetime)" + t-options='{"widget": "monetary", "display_currency": o.company_currency_id}'/> + </div> + <div name="sar_total" class="col-auto"> + <strong>Total (SAR)</strong> + <p class="m-0" t-esc="o.amount_total_signed" + t-options='{"widget": "monetary", "display_currency": o.company_currency_id}'/> + </div> + </div> + </xpath> + + <xpath expr="//t[@t-set='address']" position="inside"> + <div t-if="o.partner_id.l10n_sa_additional_identification_scheme and o.partner_id.l10n_sa_additional_identification_number" class="text-end mt0"> + <span t-field="o.partner_id.l10n_sa_additional_identification_scheme"/>: + <span t-field="o.partner_id.l10n_sa_additional_identification_number"/> + </div> + </xpath> + <xpath expr="//div[hasclass('col-4')]//span[@t-if="o.move_type == 'out_invoice' and o.state == 'posted'"]" position="replace"> + <span t-if="o.move_type == 'out_invoice' and o.state == 'posted'"> + <t t-if="o._l10n_sa_is_simplified()"> + Simplified Tax Invoice + </t> + <t t-else=""> + Tax Invoice + </t> + </span> + </xpath> + <xpath expr="//div[hasclass('col-4')][3]//span[@t-if="o.move_type == 'out_invoice' and o.state == 'posted'"]" position="replace"> + <span t-if="o.move_type == 'out_invoice' and o.state == 'posted'"> + <t t-if="o._l10n_sa_is_simplified()"> + Ùاتورة ضريبية مبسطة + </t> + <t t-else=""> + Ùاتورة ضريبية + </t> + </span> + </xpath> + + </template> + + </data> +</odoo> \ No newline at end of file diff --git a/addons/l10n_sa_edi/views/res_company_views.xml b/addons/l10n_sa_edi/views/res_company_views.xml new file mode 100644 index 0000000000000000000000000000000000000000..2b9049301df027a094485c8d6fba255181001848 --- /dev/null +++ b/addons/l10n_sa_edi/views/res_company_views.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + <record id="view_company_form" model="ir.ui.view"> + <field name="name">res.company.l10n_sa_edi.form</field> + <field name="model">res.company</field> + <field name="inherit_id" ref="base.view_company_form"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='country_id']" position="after"> + <field name="l10n_sa_edi_building_number" placeholder="Building Number" + attrs="{'invisible': [('country_code', '!=', 'SA')]}" + class="o_address_building_number" options='{"no_open": True, "no_create": True}'/> + <field name="l10n_sa_edi_plot_identification" placeholder="Plot Identification" + attrs="{'invisible': [('country_code', '!=', 'SA')]}" + class="o_address_plot_identification" options='{"no_open": True, "no_create": True}'/> + </xpath> + <xpath expr="//field[@name='vat']" position="before"> + <field name="l10n_sa_additional_identification_scheme" attrs="{'invisible': [('country_code', '!=', 'SA')]}"/> + <field name="l10n_sa_additional_identification_number" attrs="{'invisible': ['|', ('country_code', '!=', 'SA'), ('l10n_sa_additional_identification_scheme', '=', 'TIN')]}"/> + </xpath> + </field> + </record> + </data> +</odoo> diff --git a/addons/l10n_sa_edi/views/res_config_settings_view.xml b/addons/l10n_sa_edi/views/res_config_settings_view.xml new file mode 100644 index 0000000000000000000000000000000000000000..9f287ba48040141d589bb228dbf5b5d63bb75085 --- /dev/null +++ b/addons/l10n_sa_edi/views/res_config_settings_view.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + + <record model="ir.ui.view" id="res_config_settings_view_form"> + <field name="name">res.config.settings.view.form.inherit.l10n_sa_edi</field> + <field name="model">res.config.settings</field> + <field name="inherit_id" ref="account.res_config_settings_view_form"/> + <field name="arch" type="xml"> + <xpath expr="//div[@data-key='account']/div" position="after"> + <field name="country_code" invisible="1"/> + <h2 attrs="{'invisible':[('country_code', '!=', 'SA')]}">ZATCA E-Invoicing Settings</h2> + <div class="row mt16 o_settings_container" name="saudi_zatca_edi" attrs="{'invisible':[('country_code', '!=', 'SA')]}"> + <div class="col-12 o_setting_box"> + <div class="o_setting_left_pane"/> + <div class="o_setting_right_pane"> + <span class="o_form_label">ZATCA API Integration</span> + <span class="fa fa-lg fa-building-o" title="Values set here are company-specific." aria-label="Values set here are company-specific." groups="base.group_multi_company" role="img"/> + <div class="text-muted"> + 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. + </div> + <div class="content-group"> + <div class="row mt8"> + <label for="l10n_sa_api_mode" class="col-2 o_light_label" string="API Mode"/> + <field name="l10n_sa_api_mode" help="Set whether the system should use the Production API"/> + </div> + </div> + <div class="alert alert-warning mt8" role="alert"> + <h4 class="alert-heading" role="alert"> + <i class="fa fa-warning me-2" /> Warning + </h4> + Once you change the submission mode to <strong>Production</strong>, 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 <strong>Fines & Penalties</strong>. + </div> + </div> + </div> + </div> + </xpath> + </field> + </record> + +</odoo> 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 0000000000000000000000000000000000000000..56d652d43967de4de6d9201df0b2dace4dc86a6e --- /dev/null +++ b/addons/l10n_sa_edi/views/res_partner_views.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + + <record id="view_partner_form" model="ir.ui.view"> + <field name="name">res.partner.l10n_sa_edi.form</field> + <field name="model">res.partner</field> + <field name="inherit_id" ref="base.view_partner_form"/> + <field name="arch" type="xml"> + <field name="vat" position="before"> + <field name="l10n_sa_additional_identification_scheme" attrs="{'invisible': [('country_code', '!=', 'SA')]}"/> + <field name="l10n_sa_additional_identification_number" attrs="{'invisible': ['|', ('country_code', '!=', 'SA'), ('l10n_sa_additional_identification_scheme', '=', 'TIN')]}"/> + </field> + </field> + </record> + + </data> +</odoo> diff --git a/addons/l10n_sa_edi/wizard/__init__.py b/addons/l10n_sa_edi/wizard/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..55a601f2cee42904e7fc2a6f4ef4175d1c1b0448 --- /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 0000000000000000000000000000000000000000..f3f7b87750b3eac38418da79a12300fb6fb403e7 --- /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 0000000000000000000000000000000000000000..090c6dd133c9a62d3863c61f6f2e6967b1ebe103 --- /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): + 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() 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 0000000000000000000000000000000000000000..b4dbda3d9eed3832868ad8b8bcf5f23e781491b3 --- /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 0000000000000000000000000000000000000000..7d7e6b88b312fe1fa6dd2d48c7935f5a62ab94ae --- /dev/null +++ b/addons/l10n_sa_edi/wizard/l10n_sa_edi_otp_wizard.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + + <record id="l10n_sa_edi_otp_wizard_view_form" model="ir.ui.view"> + <field name="name">l10n_sa_edi.otp.wizard.form</field> + <field name="model">l10n_sa_edi.otp.wizard</field> + <field name="arch" type="xml"> + <form string="Use an OTP to request for a CSID"> + Please, set the OTP you received from ZATCA in the input below then validate. + <group> + <field name="journal_id" invisible="1"/> + <field name="l10n_sa_renewal" invisible="1"/> + <field name="l10n_sa_otp"/> + </group> + <footer> + <button string="Request" type="object" name="validate" class="btn btn-primary" data-hotkey="q"/> + <button string="Cancel" special="cancel" data-hotkey="z" class="btn btn-secondary"/> + </footer> + </form> + </field> + </record> + + <record id="l10n_sa_edi_otp_wizard_act_window" model="ir.actions.act_window"> + <field name="name">Request a CSID</field> + <field name="res_model">l10n_sa_edi.otp.wizard</field> + <field name="view_mode">form</field> + <field name="target">new</field> + </record> + +</odoo>