19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:34 +01:00
parent c5006a6999
commit 80293571e7
420 changed files with 21812 additions and 44297 deletions

View file

@ -12,40 +12,18 @@ pip install odoo-bringout-oca-ocb-l10n_sa_edi
## Dependencies
This addon depends on:
- account_edi
- account_edi_ubl_cii
- account_debit_note
- l10n_sa
- base_vat
## Manifest Information
- **Name**: Saudi Arabia - E-invoicing
- **Version**: 0.2
- **Category**: Accounting/Localizations/EDI
- **License**: LGPL-3
- **Installable**: False
- certificate
## Source
Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `l10n_sa_edi`.
- Repository: https://github.com/OCA/OCB
- Branch: 19.0
- Path: addons/l10n_sa_edi
## License
This package maintains the original LGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Reports: doc/REPORTS.md
- Security: doc/SECURITY.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md
This package preserves the original LGPL-3 license.

View file

@ -1,2 +1,19 @@
# -*- coding: utf-8 -*-
from . import models, wizard
def _l10n_sa_edi_post_init(env):
for company in env['res.company'].search([('chart_template', '=', 'sa'), ('parent_id', '=', False)]):
Template = env['account.chart.template'].with_company(company)
tax_data = Template._get_sa_edi_account_tax()
tax_data = {
xmlid: values
for xmlid, values in tax_data.items()
if Template.ref(xmlid, raise_if_not_found=False)
}
# Update existing taxes only
if tax_data:
Template._load_data({
'account.tax': tax_data,
})

View file

@ -3,15 +3,16 @@
{
'name': 'Saudi Arabia - E-invoicing',
'icon': '/l10n_sa/static/description/icon.png',
'version': '0.2',
'depends': [
'account_edi_ubl_cii',
'account_debit_note',
'l10n_sa',
'base_vat'
],
'author': 'Odoo S.A.',
'countries': ['sa'],
'version': '0.3',
'depends': [
'account_edi',
'account_edi_ubl_cii',
'l10n_sa',
'base_vat',
'certificate',
],
'summary': """
E-Invoicing, Universal Business Language
""",
@ -20,13 +21,13 @@ E-invoice implementation for Saudi Arabia; Integration with ZATCA
""",
'category': 'Accounting/Localizations/EDI',
'license': 'LGPL-3',
'post_init_hook': '_l10n_sa_edi_post_init',
'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',
'wizard/account_move_reversal_views.xml',
'views/account_tax_views.xml',
'views/account_journal_views.xml',
'views/res_partner_views.xml',

View file

@ -9,24 +9,24 @@
<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="street" placeholder="Street..." class="o_address_street"
readonly="type == 'contact' and parent_id" force_save="1"/>
<field name="street2" placeholder="District..." class="o_address_street"
readonly="type == 'contact' and parent_id" force_save="1"/>
<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)]}"/>
readonly="type == 'contact' and parent_id" force_save="1"/>
<field name="state_id" class="o_address_state" placeholder="State" options='{"no_open": True}'
readonly="type == 'contact' and parent_id" force_save="1"/>
<field name="zip" placeholder="ZIP" class="o_address_zip"
attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}"/>
readonly="type == 'contact' and parent_id" force_save="1"/>
<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"
readonly="type == 'contact' and parent_id" force_save="1"/>
<field name="l10n_sa_edi_building_number" placeholder="Building Number" invisible="country_code != 'SA'"
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"
readonly="type == 'contact' and parent_id" force_save="1"/>
<field name="l10n_sa_edi_plot_identification" placeholder="Plot Identification" invisible="country_code != 'SA'"
class="o_address_plot_identification" options='{"no_open": True, "no_create": True}'
attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}"/>
readonly="type == 'contact' and parent_id" force_save="1"/>
</div>
</form>
</field>

View file

@ -0,0 +1,16 @@
"id","l10n_sa_exemption_reason_code"
"sa_local_sales_tax_0","VATEX-SA-OOS"
"sa_export_sales_tax_0","VATEX-SA-32"
"sa_export_services_tax_0","VATEX-SA-33"
"sa_international_transport_goods_tax_0","VATEX-SA-34-1"
"sa_international_transport_passengers_tax_0","VATEX-SA-34-2"
"sa_international_transport_passengers_services_tax_0","VATEX-SA-34-3"
"sa_qualifying_transport_tax_0","VATEX-SA-34-4"
"sa_any_goods_passengers_transport_tax_0","VATEX-SA-34-5"
"sa_medicines_tax_0","VATEX-SA-35"
"sa_qualifying_metals_tax_0","VATEX-SA-36"
"sa_private_education_tax_0","VATEX-SA-EDU"
"sa_private_healthcare_tax_0","VATEX-SA-HEA"
"sa_exempt_sales_tax_0","VATEX-SA-29"
"sa_exempt_life_insurance_tax_0","VATEX-SA-29-7"
"sa_exempt_real_estate_tax_0","VATEX-SA-30"
1 id l10n_sa_exemption_reason_code
2 sa_local_sales_tax_0 VATEX-SA-OOS
3 sa_export_sales_tax_0 VATEX-SA-32
4 sa_export_services_tax_0 VATEX-SA-33
5 sa_international_transport_goods_tax_0 VATEX-SA-34-1
6 sa_international_transport_passengers_tax_0 VATEX-SA-34-2
7 sa_international_transport_passengers_services_tax_0 VATEX-SA-34-3
8 sa_qualifying_transport_tax_0 VATEX-SA-34-4
9 sa_any_goods_passengers_transport_tax_0 VATEX-SA-34-5
10 sa_medicines_tax_0 VATEX-SA-35
11 sa_qualifying_metals_tax_0 VATEX-SA-36
12 sa_private_education_tax_0 VATEX-SA-EDU
13 sa_private_healthcare_tax_0 VATEX-SA-HEA
14 sa_exempt_sales_tax_0 VATEX-SA-29
15 sa_exempt_life_insurance_tax_0 VATEX-SA-29-7
16 sa_exempt_real_estate_tax_0 VATEX-SA-30

View file

@ -1,15 +1,6 @@
<?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"
@ -28,9 +19,9 @@
<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"/>
Algorithm="http://www.w3.org/2006/12/xml-c14n11"/>
<ds:SignatureMethod
Algorithm="http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"/>
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">
@ -73,16 +64,16 @@
<xades:Cert>
<xades:CertDigest>
<ds:DigestMethod
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
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#"/>
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"/>
</xades:CertDigest>
<xades:IssuerSerial>
<ds:X509IssuerName
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"/>
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"/>
<ds:X509SerialNumber
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"/>
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"/>
</xades:IssuerSerial>
</xades:Cert>
</xades:SigningCertificate>
@ -106,180 +97,19 @@
<xades:Cert>
<xades:CertDigest>
<ds:DigestMethod xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
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"/>
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"/>
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>

View file

@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="l10n_sa.partner_demo_company_sa" model="res.partner">
<record id="base.partner_demo_company_sa" model="res.partner">
<field name="vat">399999999900003</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">3999999999</field>
<field name="l10n_sa_edi_additional_identification_scheme">OTH</field>
<field name="l10n_sa_edi_additional_identification_number">3999999999</field>
</record>
<record id="partner_demo_simplified" model="res.partner">
@ -21,7 +21,7 @@
<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>
<field name="l10n_sa_edi_additional_identification_number">123456789</field>
</record>
<record id="partner_demo_standard" model="res.partner">
@ -39,14 +39,14 @@
<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>
<field name="l10n_sa_edi_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"/>
('company_id', '=', ref('base.demo_company_sa'))], limit=1).ids"/>
</function>
</odoo>

File diff suppressed because it is too large Load diff

View file

@ -1,14 +0,0 @@
from odoo import _, api, SUPERUSER_ID
def migrate(cr, version):
env = api.Environment(cr, SUPERUSER_ID, {})
zatca_format = env.ref('l10n_sa_edi.edi_sa_zatca')
journals = env["account.journal"].search([
("edi_format_ids", "in", zatca_format.id),
("l10n_sa_compliance_checks_passed", "=", True),
("l10n_sa_production_csid_json", "!=", False)])
journals.activity_schedule(
act_type_xmlid='mail.mail_activity_data_warning',
user_id=env.ref("base.user_admin").id,
note=_('Please Re-Onboard the Journal for a new serial number'))

View file

@ -0,0 +1,22 @@
from psycopg2.extras import execute_values
EXEMPTION_REASON_MAPPING = [
('_sa_local_sales_tax_0', 'VATEX-SA-OOS'),
('_sa_export_sales_tax_0', 'VATEX-SA-32'),
('_sa_exempt_sales_tax_0', 'VATEX-SA-29'),
]
def migrate(cr, version):
# Set correct exemption reason codes for Saudi 0% and exempt taxes.
execute_values(cr, """
WITH reason_map(rec_name, exemption_code) AS (
VALUES %s
)
UPDATE account_tax AS t
SET l10n_sa_exemption_reason_code = reason_map.exemption_code
FROM ir_model_data AS imd
JOIN reason_map ON imd.name ~ reason_map.rec_name
WHERE imd.model = 'account.tax'
AND imd.res_id = t.id;
""", EXEMPTION_REASON_MAPPING)

View file

@ -1,10 +1,13 @@
# -*- coding: utf-8 -*-
from . import account_chart_template
from . import account_edi_format
from . import account_edi_xml_ubl_21_zatca
from . import account_journal
from . import account_move_send
from . import account_move
from . import account_tax
from . import certificate
from . import ir_attachment
from . import res_partner
from . import res_company
from . import res_config_settings
from . import account_edi_xml_ubl_21_zatca
from . import ir_attachment

View file

@ -0,0 +1,10 @@
from odoo import models
from odoo.addons.account.models.chart_template import template
class AccountChartTemplate(models.AbstractModel):
_inherit = 'account.chart.template'
@template('sa', 'account.tax')
def _get_sa_edi_account_tax(self):
return self._parse_csv('sa', 'account.tax', module='l10n_sa_edi')

View file

@ -7,11 +7,6 @@ from lxml import etree
from datetime import 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
_logger = logging.getLogger(__name__)
@ -48,9 +43,7 @@ class AccountEdiFormat(models.Model):
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)
return company_id.sudo().l10n_sa_private_key_id._sign(decoded_hash, formatting='base64')
def _l10n_sa_calculate_signed_properties_hash(self, issuer_name, serial_number, signing_time, public_key):
"""
@ -76,7 +69,7 @@ class AccountEdiFormat(models.Model):
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):
def _l10n_sa_sign_xml(self, xml_content, certificate, signature):
"""
Function that signs XML content of a UBL document with a provided B64 encoded X509 certificate
"""
@ -87,13 +80,12 @@ class AccountEdiFormat(models.Model):
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())
der_cert = certificate._get_der_certificate_bytes(formatting='base64')
issuer_name = ', '.join([s.rfc4514_string() for s in x509_certificate.issuer.rdns[::-1]])
serial_number = str(x509_certificate.serial_number)
issuer_name = certificate._l10n_sa_get_issuer_name()
serial_number = 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()
public_key_hashing = b64encode(sha256(der_cert).hexdigest().encode()).decode()
signed_properties_hash = self._l10n_sa_calculate_signed_properties_hash(issuer_name, serial_number,
signing_time, public_key_hashing)
@ -108,7 +100,7 @@ class AccountEdiFormat(models.Model):
'digest')
_set_content("//*[local-name()='SignatureValue']", signature)
_set_content("//*[local-name()='X509Certificate']", b64_decoded_cert.decode())
_set_content("//*[local-name()='X509Certificate']", der_cert.decode())
_set_content("//*[local-name()='SignatureInformation']//*[local-name()='DigestValue']", invoice_hash)
_set_content("//*[@URI='#xadesSignedProperties']/*[local-name()='DigestValue']", signed_properties_hash)
@ -120,9 +112,9 @@ class AccountEdiFormat(models.Model):
"""
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'}
return {'error': _("Invoice could not be cleared:\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 {'error': _("Invoice could not be reported:\n%s", clearance_data), 'blocking_level': 'error'}
return clearance_data
# ====== UBL Document Rendering & Submission =======
@ -153,7 +145,7 @@ class AccountEdiFormat(models.Model):
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),
'error': _("Could not generate Invoice UBL content: %s", ", \n".join(errors)),
'blocking_level': 'error'
}
return self._l10n_sa_postprocess_zatca_template(xml_content)
@ -165,11 +157,9 @@ class AccountEdiFormat(models.Model):
- 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'):
error = clearance_data['json_errors']
if error := clearance_data.get('json_errors'):
error_msg = ''
status_code = error.get('status_code')
if status_code:
if status_code := error.get('status_code'):
error_msg = Markup("<b>[%s] </b>") % status_code
is_warning = True
@ -211,12 +201,12 @@ class AccountEdiFormat(models.Model):
qr_node.text = qr_code
return etree.tostring(root, with_tail=False)
def _l10n_sa_get_signed_xml(self, invoice, unsigned_xml, x509_cert):
def _l10n_sa_get_signed_xml(self, invoice, unsigned_xml, certificate):
"""
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)
signed_xml = self._l10n_sa_sign_xml(unsigned_xml, certificate, invoice.l10n_sa_invoice_signature)
if invoice._l10n_sa_is_simplified():
# Applying with_prefetch() to set the _prefetch_ids = _ids,
# preventing premature QR code computation for other invoices.
@ -234,23 +224,24 @@ class AccountEdiFormat(models.Model):
# 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
# Load PCISD data and certificate
try:
PCSID_data = invoice.journal_id._l10n_sa_api_get_pcsid()
PCSID_data, certificate = invoice.journal_id._l10n_sa_api_get_pcsid()
except UserError as e:
return ({
'error': _("Could not generate PCSID values: \n") + e.args[0],
'error': e.args[0],
'blocking_level': 'error',
'response': unsigned_xml
}, unsigned_xml)
x509_cert = PCSID_data['binarySecurityToken']
certificate_sudo = self.env['certificate.certificate'].sudo().browse(certificate)
# 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:
signed_xml = self._l10n_sa_get_signed_xml(invoice, unsigned_xml, certificate_sudo)
except UserError:
return ({
'error': _("Could not generate signed XML values: \n") + e.args[0],
'error': _("Something went wrong. Please retry, and if that does not work, then onboard the journal again."),
'blocking_level': 'error',
'response': unsigned_xml
}, unsigned_xml)
@ -258,65 +249,44 @@ class AccountEdiFormat(models.Model):
# 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)
missing_fields = []
if not partner_id.state_id:
missing_fields.append(_('State'))
if not partner_id.city:
missing_fields.append(_('City'))
return missing_fields
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: line.display_type == 'product').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)
partner_id = invoice.commercial_partner_id
missing = []
identification_scheme = partner_id.l10n_sa_edi_additional_identification_scheme
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: line.display_type == 'product'
).tax_ids
)
and (
identification_scheme != 'NAT'
or not partner_id.l10n_sa_edi_additional_identification_number
)
):
missing.append(_(
"Please set the Identification Scheme as National ID and Identification Number as the respective "
"number on the Customer as the Tax Exemption Reason is set either as VATEX-SA-HEA or VATEX-SA-EDU"
))
if identification_scheme == 'TIN' and not partner_id.vat:
missing.append(_("Please set the VAT Number as the Identification Scheme is Tax Identification Number"))
return missing
def _l10n_sa_post_zatca_edi(self, invoice): # no batch ensure that there is only one invoice
"""
@ -329,8 +299,9 @@ class AccountEdiFormat(models.Model):
# 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():
invoice.l10n_sa_edi_chain_head_id = chain_head
return {invoice: {
'error': f"ZATCA: Cannot post invoice while chain head ({chain_head.name}) has not been posted",
'error': _("Error: This invoice is blocked due to %s. Please check it.", chain_head.name),
'blocking_level': 'error',
'response': None,
}}
@ -372,8 +343,13 @@ class AccountEdiFormat(models.Model):
# Once submission is done with no errors, check submission status
cleared_xml = self._l10n_sa_postprocess_einvoice_submission(invoice, submitted_xml, response_data)
# Set 'l10n_sa_edi_is_production' to True upon the first invoice submission in Production mode
if not invoice.company_id.l10n_sa_edi_is_production:
invoice.company_id.l10n_sa_edi_is_production = invoice.company_id.l10n_sa_api_mode == 'prod'
# Save the submitted/returned invoice XML content once the submission has been completed successfully
invoice._l10n_sa_log_results(cleared_xml.encode(), response_data)
invoice.journal_id._l10n_sa_reset_chain_head_error()
return {
invoice: {
'success': True,
@ -406,48 +382,54 @@ class AccountEdiFormat(models.Model):
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':
if self.code != 'sa_zatca' or company.country_id and 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"))
errors.append(_("- Invoice cannot be posted as the Supplier and Buyer are the same."))
if not all(line.tax_ids for line in invoice.invoice_line_ids.filtered(lambda line: line.display_type == 'product')):
errors.append(_("- Invoice lines should have at least one Tax applied."))
if not all(line.tax_ids for line in invoice.invoice_line_ids.filtered(lambda line: line.display_type == 'product' and line._check_edi_line_tax_required())):
errors.append(_("- Invoice lines need at least one tax. Please input it and try again."))
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)
errors.append(_("- The Journal (%s) is not onboarded yet. Please onboard it and try again.", 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:
if not journal.company_id.sudo().l10n_sa_private_key_id:
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)
_("- No Private Key was generated for company %s. A Private Key is mandatory in order to generate Certificate Signing Requests (CSR).", company.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 > fields.Date.context_today(self.with_context(tz='Asia/Riyadh')):
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 either the Reversed Entry or the Reversal Reason are specified when confirming a Credit/Debit note"))
_(
"- Please set the following fields on the %(company_name)s: %(missing_fields)s",
company_name=company.name,
missing_fields=", ".join(supplier_missing_info),
)
)
if customer_missing_info:
errors.append(
_(
"- %(missing_info)s",
missing_info=", ".join(customer_missing_info),
)
)
if invoice.invoice_date > fields.Date.context_today(self.with_context(tz='Asia/Riyadh')):
errors.append(_("- Please set the Invoice Date to be either less than or equal to today as per the Asia/Riyadh time zone, since ZATCA does not allow future-dated invoicing."))
if invoice.l10n_sa_show_reason and not invoice.l10n_sa_reason:
errors.append(_("- Please make sure the 'ZATCA Reason' for the issuance of the Credit/Debit Note is specified."))
if invoice.l10n_sa_show_reason and not invoice._l10n_sa_check_billing_reference():
errors.append(_("- Please make sure the 'Customer Reference' contains the sequential number of the original invoice(s) that the Credit/Debit Note is related to."))
return errors
def _needs_web_services(self):
@ -497,7 +479,7 @@ class AccountEdiFormat(models.Model):
attachment = edi_document.sudo().attachment_id
if not attachment or not attachment.datas:
_logger.warning(f"No attachment found for invoice {edi_document.move_id.name}")
_logger.warning("No attachment found for invoice %s", edi_document.move_id.name)
return
xml_content = attachment.raw
@ -507,8 +489,8 @@ class AccountEdiFormat(models.Model):
if not pdf_writer.is_pdfa:
try:
pdf_writer.convert_to_pdfa()
except Exception as e:
_logger.exception("Error while converting to PDF/A: %s", e)
except Exception:
_logger.exception("Error while converting to PDF/A")
content = self.env['ir.qweb']._render(
'account_edi_ubl_cii.account_invoice_pdfa_3_facturx_metadata',
{
@ -516,4 +498,6 @@ class AccountEdiFormat(models.Model):
'date': fields.Date.context_today(self),
},
)
if "<pdfaid:conformance>B</pdfaid:conformance>" in content:
content.replace("<pdfaid:conformance>B</pdfaid:conformance>", "<pdfaid:conformance>A</pdfaid:conformance>")
pdf_writer.add_file_metadata(content.encode())

View file

@ -3,7 +3,7 @@ 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
from odoo.tools.misc import file_path
import re
TAX_EXEMPTION_CODES = ['VATEX-SA-29', 'VATEX-SA-29-7', 'VATEX-SA-30']
@ -19,11 +19,564 @@ PAYMENT_MEANS_CODE = {
}
class AccountEdiXmlUBL21Zatca(models.AbstractModel):
_name = "account.edi.xml.ubl_21.zatca"
_inherit = 'account.edi.xml.ubl_21'
class AccountEdiXmlUbl_21Zatca(models.AbstractModel):
_name = 'account.edi.xml.ubl_21.zatca'
_inherit = ['account.edi.xml.ubl_21']
_description = "UBL 2.1 (ZATCA)"
# -------------------------------------------------------------------------
# EXPORT
# -------------------------------------------------------------------------
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(r'[^a-zA-Z0-9 -]+', '-', invoice.name)
invoice_date = fields.Datetime.context_timestamp(self.with_context(tz='Asia/Riyadh'), invoice.l10n_sa_confirmation_datetime)
file_name = f"{vat}_{invoice_date.strftime('%Y%m%dT%H%M%S')}_{invoice_number}"
file_format = self.env.context.get('l10n_sa_file_format', 'xml')
if file_format:
file_name = f'{file_name}.{file_format}'
return file_name
def _add_invoice_config_vals(self, vals):
super()._add_invoice_config_vals(vals)
vals['document_type'] = 'invoice' # Only use Invoice in ZATCA, even for credit and debit notes
def _add_invoice_base_lines_vals(self, vals):
super()._add_invoice_base_lines_vals(vals)
invoice = vals['invoice']
# Filter out prepayment lines of final invoices
if not invoice._is_downpayment():
vals['base_lines'] = [
base_line
for base_line in vals['base_lines']
if not base_line['record']._get_downpayment_lines()
]
# Get downpayment moves' base lines
if not invoice._is_downpayment():
prepayment_moves = invoice.line_ids._get_downpayment_lines().move_id.filtered(lambda m: m.move_type == 'out_invoice')
else:
prepayment_moves = self.env['account.move']
prepayment_moves_base_lines = {}
for prepayment_move in prepayment_moves:
prepayment_move_base_lines, _dummy = prepayment_move._get_rounded_base_and_tax_lines()
prepayment_moves_base_lines[prepayment_move] = prepayment_move_base_lines
vals['prepayment_moves_base_lines'] = prepayment_moves_base_lines
def _add_document_tax_grouping_function_vals(self, vals):
# OVERRIDE account.edi.xml.ubl_21
# Always ignore withholding taxes in the UBL
def total_grouping_function(base_line, tax_data):
if tax_data and tax_data['tax'].l10n_sa_is_retention:
return None
return True
def tax_grouping_function(base_line, tax_data):
tax = tax_data and tax_data['tax']
# Ignore withholding taxes
if tax and tax.l10n_sa_is_retention:
return None
return {
'tax_category_code': self._get_tax_category_code(vals['customer'].commercial_partner_id, vals['supplier'], tax),
**self._get_tax_exemption_reason(vals['customer'].commercial_partner_id, vals['supplier'], tax),
'amount': tax.amount if tax else 0.0,
'amount_type': tax.amount_type if tax else 'percent',
}
vals['total_grouping_function'] = total_grouping_function
vals['tax_grouping_function'] = tax_grouping_function
# -------------------------------------------------------------------------
# EXPORT: Helpers
# -------------------------------------------------------------------------
def _get_tax_category_code(self, customer, supplier, tax):
""" Override to include/update values specific to ZATCA's UBL 2.1 specs """
if supplier.country_id.code == 'SA':
if tax and tax.amount != 0:
return 'S'
elif tax and tax.l10n_sa_exemption_reason_code in TAX_EXEMPTION_CODES:
return 'E'
elif tax and tax.l10n_sa_exemption_reason_code in TAX_ZERO_RATE_CODES:
return 'Z'
else:
return 'O'
return super()._get_tax_category_code(customer, supplier, tax)
def _get_tax_exemption_reason(self, customer, supplier, tax):
if supplier.country_id.code == 'SA':
if tax and tax.amount == 0:
exemption_reason_by_code = dict(tax._fields["l10n_sa_exemption_reason_code"]._description_selection(self.env))
code = tax.l10n_sa_exemption_reason_code
return {
'tax_exemption_reason_code': code or "VATEX-SA-OOS",
'tax_exemption_reason': (
exemption_reason_by_code[code].split(code)[1].lstrip()
if code else "Not subject to VAT"
)
}
else:
return {
'tax_exemption_reason_code': None,
'tax_exemption_reason': None,
}
return super()._get_tax_exemption_reason(customer, supplier, tax)
def _is_document_allowance_charge(self, base_line):
return base_line['special_type'] == 'early_payment' or base_line['tax_details']['total_excluded_currency'] < 0
# -------------------------------------------------------------------------
# EXPORT: Templates for document header nodes
# -------------------------------------------------------------------------
def _add_invoice_header_nodes(self, document_node, vals):
super()._add_invoice_header_nodes(document_node, vals)
invoice = vals['invoice']
issue_date = fields.Datetime.context_timestamp(self.with_context(tz='Asia/Riyadh'), invoice.l10n_sa_confirmation_datetime)
document_node.update({
'cbc:ProfileID': {'_text': 'reporting:1.0'},
'cbc:UUID': {'_text': invoice.l10n_sa_uuid},
'cbc:IssueDate': {'_text': issue_date.strftime('%Y-%m-%d')},
'cbc:IssueTime': {'_text': issue_date.strftime('%H:%M:%S')},
'cbc:DueDate': None,
'cbc:InvoiceTypeCode': {
'_text': (
383 if invoice.debit_origin_id else
381 if invoice.move_type == 'out_refund' else
386 if invoice._is_downpayment() else 388
),
'name': '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'
),
},
'cbc:TaxCurrencyCode': {'_text': vals['company_currency_id'].name},
'cac:OrderReference': None,
'cac:BillingReference': {
'cac:InvoiceDocumentReference': {
'cbc:ID': {
'_text': (invoice.reversed_entry_id.name or invoice.ref)
if invoice.move_type == 'out_refund'
else invoice.debit_origin_id.name
}
}
} if invoice.move_type == 'out_refund' or invoice.debit_origin_id else None,
'cac:AdditionalDocumentReference': [
{
'cbc:ID': {'_text': 'QR'},
'cac:Attachment': {
'cbc:EmbeddedDocumentBinaryObject': {
'_text': 'N/A',
'mimeCode': 'text/plain',
}
}
} if invoice._l10n_sa_is_simplified() else None,
{
'cbc:ID': {'_text': 'PIH'},
'cac:Attachment': {
'cbc:EmbeddedDocumentBinaryObject': {
'_text': (
"NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ=="
if invoice.company_id.l10n_sa_api_mode == 'sandbox' or not invoice.journal_id.l10n_sa_latest_submission_hash
else invoice.journal_id.l10n_sa_latest_submission_hash
),
'mimeCode': 'text/plain',
}
}
},
{
'cbc:ID': {'_text': 'ICV'},
'cbc:UUID': {'_text': invoice.l10n_sa_chain_index},
}
],
'cac:Signature': {
'cbc:ID': {'_text': "urn:oasis:names:specification:ubl:signature:Invoice"},
'cbc:SignatureMethod': {'_text': "urn:oasis:names:specification:ubl:dsig:enveloped:xades"},
} if invoice._l10n_sa_is_simplified() else None,
'cac:PaymentTerms': None,
})
def _add_invoice_delivery_nodes(self, document_node, vals):
super()._add_invoice_delivery_nodes(document_node, vals)
invoice = vals['invoice']
if 'cac:Delivery' in document_node:
if document_node['cac:Delivery']['cac:DeliveryLocation']:
document_node['cac:Delivery']['cac:DeliveryLocation'] = None
if not document_node['cac:Delivery']['cbc:ActualDeliveryDate']['_text']:
document_node['cac:Delivery']['cbc:ActualDeliveryDate'] = {'_text': invoice.invoice_date}
def _add_invoice_payment_means_nodes(self, document_node, vals):
""" Override to include/update values specific to ZATCA's UBL 2.1 specs """
super()._add_invoice_payment_means_nodes(document_node, vals)
payment_means_node = document_node['cac:PaymentMeans']
invoice = vals['invoice']
payment_means_node['cbc:PaymentMeansCode'] = {
'_text': PAYMENT_MEANS_CODE.get(
self._l10n_sa_get_payment_means_code(invoice),
PAYMENT_MEANS_CODE['unknown']
),
'listID': 'UN/ECE 4461',
}
payment_means_node['cbc:InstructionNote'] = {'_text': invoice._l10n_sa_get_adjustment_reason()}
def _get_address_node(self, vals):
partner = vals['partner']
country = partner['country' if partner._name == 'res.bank' else 'country_id']
state = partner['state' if partner._name == 'res.bank' else 'state_id']
building_number = partner.l10n_sa_edi_building_number if partner._name == 'res.partner' else ''
edi_plot_identification = partner.l10n_sa_edi_plot_identification if partner._name == 'res.partner' else ''
return {
'cbc:StreetName': {'_text': partner.street},
'cbc:BuildingNumber': {'_text': building_number},
'cbc:PlotIdentification': {'_text': edi_plot_identification},
'cbc:CitySubdivisionName': {'_text': partner.street2},
'cbc:CityName': {'_text': partner.city},
'cbc:PostalZone': {'_text': partner.zip},
'cbc:CountrySubentity': {'_text': state.name},
'cbc:CountrySubentityCode': {'_text': state.code},
'cac:AddressLine': None,
'cac:Country': {
'cbc:IdentificationCode': {'_text': country.code},
'cbc:Name': {'_text': country.name},
}
}
def _get_partner_party_identification_number(self, partner):
""" Override to include/update values specific to ZATCA's UBL 2.1 specs """
identification_number = partner.l10n_sa_edi_additional_identification_number
vat = re.sub(r'[^a-zA-Z0-9]', '', partner.vat or "")
if partner.country_code != "SA" and vat:
identification_number = vat
elif partner.l10n_sa_edi_additional_identification_scheme == 'TIN':
# according to ZATCA, the TIN number is always the first 10 digits of the VAT number
identification_number = vat[:10]
return identification_number
def _get_party_node(self, vals):
partner = vals['partner']
role = vals['role']
commercial_partner = partner.commercial_partner_id
party_node = {}
if identification_number := self._get_partner_party_identification_number(commercial_partner):
party_node['cac:PartyIdentification'] = {
'cbc:ID': {
'_text': identification_number,
'schemeID': commercial_partner.l10n_sa_edi_additional_identification_scheme,
}
}
party_node.update({
'cac:PartyName': {
'cbc:Name': {'_text': partner.display_name}
},
'cac:PostalAddress': self._get_address_node(vals),
'cac:PartyTaxScheme': {
'cbc:RegistrationName': {'_text': commercial_partner.name},
'cbc:CompanyID': {'_text': commercial_partner.vat},
'cac:RegistrationAddress': self._get_address_node({'partner': commercial_partner}),
'cac:TaxScheme': {
'cbc:ID': {'_text': 'VAT'}
}
} if (role != 'customer' or partner.country_id.code == 'SA') and commercial_partner.vat and commercial_partner.vat != '/' else None, # BR-KSA-46
'cac:PartyLegalEntity': {
'cbc:RegistrationName': {'_text': commercial_partner.name},
'cbc:CompanyID': {'_text': commercial_partner.vat} if commercial_partner.country_code == 'SA' else None,
'cac:RegistrationAddress': self._get_address_node({'partner': commercial_partner}),
},
'cac:Contact': {
'cbc:ID': {'_text': partner.id},
'cbc:Name': {'_text': partner.name},
'cbc:Telephone': {
'_text': re.sub(r"[^+\d]", '', partner.phone) if partner.phone else None
},
'cbc:ElectronicMail': {'_text': partner.email},
}
})
return party_node
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'
# -------------------------------------------------------------------------
# EXPORT: Templates for document amount nodes
# -------------------------------------------------------------------------
def _add_document_tax_total_nodes(self, document_node, vals):
super()._add_document_tax_total_nodes(document_node, vals)
document_node['cac:TaxTotal'] = [document_node['cac:TaxTotal']]
self._add_tax_total_node_in_company_currency(document_node, vals)
document_node['cac:TaxTotal'][1]['cac:TaxSubtotal'] = None
def _add_invoice_monetary_total_nodes(self, document_node, vals):
super()._add_invoice_monetary_total_nodes(document_node, vals)
monetary_total_tag = 'cac:LegalMonetaryTotal' if vals['document_type'] in {'invoice', 'credit_note'} else 'cac:RequestedMonetaryTotal'
monetary_total_node = document_node[monetary_total_tag]
# Compute the prepaid amount
prepaid_amount = 0.0
AccountTax = self.env['account.tax']
for prepayment_move_base_lines in vals['prepayment_moves_base_lines'].values():
# Compute prepayment moves' totals
prepayment_moves_base_lines_aggregated_tax_details = AccountTax._aggregate_base_lines_tax_details(
prepayment_move_base_lines,
vals['total_grouping_function'],
)
prepayment_moves_total_aggregated_tax_details = AccountTax._aggregate_base_lines_aggregated_values(prepayment_moves_base_lines_aggregated_tax_details)
for grouping_key, values in prepayment_moves_total_aggregated_tax_details.items():
if grouping_key:
prepaid_amount += values['base_amount_currency'] + values['tax_amount_currency']
# AllowanceTotalAmount is always present even if 0.0
monetary_total_node['cbc:AllowanceTotalAmount'] = {
'_text': self.format_float(vals['total_allowance_currency'], vals['currency_dp']),
'currencyID': vals['currency_name'],
}
monetary_total_node['cbc:PrepaidAmount']['_text'] = self.format_float(prepaid_amount, vals['currency_dp'])
monetary_total_node['cbc:PayableAmount']['_text'] = self.format_float(vals['tax_inclusive_amount_currency'] - prepaid_amount, vals['currency_dp'])
# -------------------------------------------------------------------------
# EXPORT: Templates for document allowance charge nodes
# -------------------------------------------------------------------------
def _get_document_allowance_charge_node(self, vals):
""" Charge Reasons & Codes (As per ZATCA):
https://unece.org/fileadmin/DAM/trade/untdid/d16b/tred/tred5189.htm
As far as ZATCA is concerned, we calculate Allowance/Charge vals for global discounts as
a document level allowance, and we do not include any other charges or allowances.
"""
base_line = vals['base_line']
aggregated_tax_details = self.env['account.tax']._aggregate_base_line_tax_details(base_line, vals['tax_grouping_function'])
base_amount_currency = base_line['tax_details']['total_excluded_currency']
if base_line['special_type'] == 'early_payment':
return super()._get_document_allowance_charge_node(vals)
elif base_amount_currency < 0:
return {
'cbc:ChargeIndicator': {'_text': 'false'},
'cbc:AllowanceChargeReasonCode': {'_text': '95'},
'cbc:AllowanceChargeReason': {'_text': 'Discount'},
'cbc:Amount': {
'_text': self.format_float(abs(base_amount_currency), 2),
'currencyID': vals['currency_id'].name,
},
'cac:TaxCategory': [
self._get_tax_category_node({**vals, 'grouping_key': grouping_key})
for grouping_key in aggregated_tax_details
if grouping_key
]
}
# -------------------------------------------------------------------------
# EXPORT: Templates for document line nodes
# -------------------------------------------------------------------------
def _add_invoice_line_nodes(self, document_node, vals):
document_line_tag = self._get_tags_for_document_type(vals)['document_line']
document_node[document_line_tag] = []
line_idx = 1
# First: the non-prepayment lines from the invoice
for base_line in vals['base_lines']:
if not self._is_document_allowance_charge(base_line):
line_vals = {
**vals,
'line_idx': line_idx,
'base_line': base_line,
}
self._add_invoice_line_vals(line_vals)
line_node = {}
self._add_invoice_line_id_nodes(line_node, line_vals)
self._add_invoice_line_amount_nodes(line_node, line_vals)
self._add_invoice_line_period_nodes(line_node, line_vals)
self._add_invoice_line_allowance_charge_nodes(line_node, line_vals)
self._add_invoice_line_tax_total_nodes(line_node, line_vals)
self._add_invoice_line_item_nodes(line_node, line_vals)
self._add_invoice_line_tax_category_nodes(line_node, line_vals)
self._add_invoice_line_price_nodes(line_node, line_vals)
self._add_invoice_line_pricing_reference_nodes(line_node, line_vals)
document_node[document_line_tag].append(line_node)
line_idx += 1
# Then: all the prepayment adjustments
for prepayment_move, prepayment_move_base_lines in vals['prepayment_moves_base_lines'].items():
document_node[document_line_tag].append(
self._get_prepayment_line_node({
**vals,
'line_idx': line_idx,
'prepayment_move': prepayment_move,
'prepayment_move_base_lines': prepayment_move_base_lines,
})
)
line_idx += 1
def _add_document_line_tax_total_nodes(self, line_node, vals):
base_line = vals['base_line']
aggregated_tax_details = self.env['account.tax']._aggregate_base_line_tax_details(base_line, vals['tax_grouping_function'])
total_tax_amount = sum(
values['tax_amount_currency']
for grouping_key, values in aggregated_tax_details.items()
if grouping_key
)
total_base_amount = sum(
values['base_amount_currency']
for grouping_key, values in aggregated_tax_details.items()
if grouping_key
)
total_amount = total_base_amount + total_tax_amount
line_node['cac:TaxTotal'] = {
'cbc:TaxAmount': {
'_text': self.format_float(total_tax_amount, vals['currency_dp']),
'currencyID': vals['currency_name'],
},
'cbc:RoundingAmount': {
# This should simply contain the net (base + tax) amount for the line.
'_text': self.format_float(total_amount, vals['currency_dp']),
'currencyID': vals['currency_name'],
},
# No TaxSubtotal: BR-KSA-80: only downpayment lines should have a tax subtotal breakdown.
}
def _add_document_line_item_nodes(self, line_node, vals):
super()._add_document_line_item_nodes(line_node, vals)
product = vals['base_line']['product_id']
line_node['cac:Item']['cac:SellersItemIdentification'] = {
'cbc:ID': {'_text': product.code or product.default_code},
}
def _add_document_line_tax_category_nodes(self, line_node, vals):
base_line = vals['base_line']
aggregated_tax_details = self.env['account.tax']._aggregate_base_line_tax_details(base_line, vals['tax_grouping_function'])
line_node['cac:Item']['cac:ClassifiedTaxCategory'] = [
self._get_tax_category_node({**vals, 'grouping_key': grouping_key})
for grouping_key in aggregated_tax_details
if grouping_key
]
def _get_prepayment_line_node(self, vals):
prepayment_move = vals['prepayment_move']
AccountTax = self.env['account.tax']
prepayment_moves_base_lines_aggregated_tax_details = AccountTax._aggregate_base_lines_tax_details(
vals['prepayment_move_base_lines'],
vals['tax_grouping_function'],
)
aggregated_tax_details = AccountTax._aggregate_base_lines_aggregated_values(prepayment_moves_base_lines_aggregated_tax_details)
prepayment_move_issue_date = fields.Datetime.context_timestamp(
self.with_context(tz='Asia/Riyadh'),
prepayment_move.l10n_sa_confirmation_datetime
)
return {
'cbc:ID': {'_text': vals['line_idx']},
'cbc:InvoicedQuantity': {
'_text': '1.0',
'unitCode': 'C62',
},
'cbc:LineExtensionAmount': {
'_text': '0.00',
'currencyID': vals['currency_name'],
},
'cac:DocumentReference': {
'cbc:ID': {'_text': prepayment_move.name},
'cbc:IssueDate': {
'_text': prepayment_move_issue_date.strftime('%Y-%m-%d') if prepayment_move_issue_date else None
},
'cbc:IssueTime': {
'_text': prepayment_move_issue_date.strftime('%H:%M:%S') if prepayment_move_issue_date else None
},
'cbc:DocumentTypeCode': {'_text': '386'},
},
'cac:TaxTotal': self._get_prepayment_line_tax_total_node({**vals, 'aggregated_tax_details': aggregated_tax_details}),
'cac:Item': {
'cbc:Description': {'_text': "Down Payment"},
'cbc:Name': {'_text': "Down Payment"},
'cac:ClassifiedTaxCategory': [
self._get_tax_category_node({**vals, 'grouping_key': grouping_key})
for grouping_key in aggregated_tax_details
]
},
'cac:Price': {
'cbc:PriceAmount': {
'_text': '0',
'currencyID': vals['currency_name'],
}
},
}
def _get_prepayment_line_tax_total_node(self, vals):
# Compute prepayment move subtotals by tax category
aggregated_tax_details = vals['aggregated_tax_details']
return {
'cbc:TaxAmount': {
'_text': '0.00',
'currencyID': vals['currency_name'],
},
'cbc:RoundingAmount': {
'_text': '0.00',
'currencyID': vals['currency_name'],
},
'cac:TaxSubtotal': [
{
'cbc:TaxableAmount': {
'_text': self.format_float(values['base_amount_currency'], vals['currency_dp']),
'currencyID': vals['currency_name'],
},
'cbc:TaxAmount': {
'_text': self.format_float(values['tax_amount_currency'], vals['currency_dp']),
'currencyID': vals['currency_name'],
},
'cbc:Percent': {'_text': grouping_key['amount']},
'cac:TaxCategory': self._get_tax_category_node({**vals, 'grouping_key': grouping_key}),
}
for grouping_key, values in aggregated_tax_details.items()
if grouping_key
],
}
def _add_document_line_price_nodes(self, line_node, vals):
"""
Use 10 decimal places for PriceAmount to satisfy ZATCA validation BR-KSA-EN16931-11
"""
currency_suffix = vals['currency_suffix']
line_node['cac:Price'] = {
'cbc:PriceAmount': {
'_text': round(vals[f'gross_price_unit{currency_suffix}'], 10),
'currencyID': vals['currency_name'],
},
}
# -------------------------------------------------------------------------
# EXPORT: Helpers for hash generation
# -------------------------------------------------------------------------
def _l10n_sa_get_namespaces(self):
"""
Namespaces used in the final UBL declaration, required to canonalize the finalized XML document of the Invoice
@ -57,7 +610,7 @@ class AccountEdiXmlUBL21Zatca(models.AbstractModel):
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'))
invoice_xsl = etree.parse(file_path('l10n_sa_edi/data/pre-hash_invoice.xsl'))
transform = etree.XSLT(invoice_xsl)
return _canonicalize_xml(transform(content))
@ -80,395 +633,3 @@ class AccountEdiXmlUBL21Zatca(models.AbstractModel):
elif mode == 'digest':
xml_hash = xml_sha.digest()
return b64encode(xml_hash)
def _l10n_sa_get_previous_invoice_hash(self, invoice):
""" Function that returns the Base 64 encoded SHA256 hash of the previously submitted invoice """
if invoice.company_id.l10n_sa_api_mode == 'sandbox' or not invoice.journal_id.l10n_sa_latest_submission_hash:
# If no invoice, or if using Sandbox, return the b64 encoded SHA256 value of the '0' character
return "NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ=="
return invoice.journal_id.l10n_sa_latest_submission_hash
def _get_delivery_vals_list(self, invoice):
""" Override to include/update values specific to ZATCA's UBL 2.1 specs """
shipping_address = invoice.partner_shipping_id
return [{'actual_delivery_date': invoice.l10n_sa_delivery_date,
'delivery_address_vals': self._get_partner_address_vals(shipping_address) if shipping_address else {},}]
def _get_partner_contact_vals(self, partner):
res = super()._get_partner_contact_vals(partner)
if res.get('telephone'):
res['telephone'] = re.sub(r"[^+\d]", '', res['telephone'])
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' and partner.country_code == 'SA'
else partner.vat
),
}]
def _get_partner_party_legal_entity_vals_list(self, partner):
# EXTEND 'account.edi.xml.ubl_20'
partners_party_legal = super()._get_partner_party_legal_entity_vals_list(partner)
for partner_party_legal in partners_party_legal:
if partner_party_legal['commercial_partner'].country_code != 'SA':
partner_party_legal['company_id'] = False
return partners_party_legal
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.get(self._l10n_sa_get_payment_means_code(invoice), PAYMENT_MEANS_CODE['unknown'])
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(r'[^a-zA-Z0-9 -]+', '-', invoice.name)
invoice_date = fields.Datetime.context_timestamp(self.with_context(tz='Asia/Riyadh'), invoice.l10n_sa_confirmation_datetime)
file_name = f"{vat}_{invoice_date.strftime('%Y%m%dT%H%M%S')}_{invoice_number}"
file_format = self.env.context.get('l10n_sa_file_format', 'xml')
if file_format:
file_name = f'{file_name}.{file_format}'
return file_name
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
386 if invoice._is_downpayment() 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'])
line_extension_amount = vals['vals']['legal_monetary_total_vals']['line_extension_amount']
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)
allowance_charge_vals = vals['vals']['allowance_charge_vals']
allowance_total_amount = sum(a['amount'] for a in allowance_charge_vals if a['charge_indicator'] == 'false')
if downpayment_vals:
# - BR-KSA-80: To calculate payable amount, we deduct prepaid amount from total tax inclusive amount
prepaid_amount = downpayment_vals['total_amount']
payable_amount = tax_inclusive_amount - prepaid_amount
return {
'line_extension_amount': line_extension_amount - allowance_total_amount,
'tax_inclusive_amount': tax_inclusive_amount,
'tax_exclusive_amount': tax_exclusive_amount,
'prepaid_amount': prepaid_amount,
'payable_amount': payable_amount,
'allowance_total_amount': allowance_total_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 _get_document_allowance_charge_vals_list(self, invoice):
"""
Charge Reasons & Codes (As per ZATCA):
https://unece.org/fileadmin/DAM/trade/untdid/d16b/tred/tred5189.htm
As far as ZATCA is concerned, we calculate Allowance/Charge vals for global discounts as
a document level allowance, and we do not include any other charges or allowances
"""
res = super()._get_document_allowance_charge_vals_list(invoice)
for line in invoice.invoice_line_ids.filtered(lambda l: l._is_global_discount_line()):
taxes = line.tax_ids.flatten_taxes_hierarchy().filtered(lambda t: t.amount_type != 'fixed')
res.append({
'charge_indicator': 'false',
'allowance_charge_reason_code': "95",
'allowance_charge_reason': "Discount",
'amount': abs(line.price_subtotal),
'currency_dp': 2,
'currency_name': invoice.currency_id.name,
'tax_category_vals': [{
'id': tax['id'],
'percent': tax['percent'],
'tax_scheme_id': 'VAT',
} for tax in self._get_tax_category_list(line.move_id, taxes)],
})
return res
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))
self._l10n_sa_postprocess_line_vals(vals)
return vals
def _l10n_sa_postprocess_line_vals(self, vals):
"""
Postprocess vals to remove negative line amounts, as those will be used to compute
document level allowances (global discounts)
"""
final_line_vals = []
for line_vals in vals['vals']['invoice_line_vals']:
if line_vals['price_vals']['price_amount'] >= 0:
final_line_vals.append(line_vals)
vals['vals']['invoice_line_vals'] = final_line_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.move_type == 'out_invoice' and 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(filter_to_apply=lambda l, t: not self.env['account.tax'].browse(t['id']).l10n_sa_is_retention, 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)
else:
# - BR-KSA-80: only down-payment lines should have a tax subtotal breakdown, as that is
# used during computation of prepaid amount as ZATCA sums up tax amount/taxable amount of all lines
# irrespective of whether they are down-payment lines.
line_vals['tax_total_vals'][0].pop('tax_subtotal_vals', None)
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 or "VATEX-SA-OOS",
'tax_exemption_reason': (
exemption_codes[reason].split(reason)[1].lstrip()
if reason else "Not subject to VAT"
)
}
supplier = invoice.company_id.partner_id.commercial_partner_id
if 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 _exemption_reason('O', tax.l10n_sa_exemption_reason_code)
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 []

View file

@ -1,20 +1,20 @@
import json
import requests
from markupsafe import Markup
from lxml import etree
import logging
from base64 import b64decode, b64encode
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
import requests
from lxml import etree
from markupsafe import Markup
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
from odoo import _, fields, models
from odoo.exceptions import UserError
from odoo.tools.misc import file_open
from odoo.tools.translate import LazyTranslate
from odoo.tools.urls import urljoin
_lt = LazyTranslate(__name__)
ZATCA_API_URLS = {
"sandbox": "https://gw-fatoora.zatca.gov.sa/e-invoicing/developer-portal/",
@ -29,17 +29,16 @@ ZATCA_API_URLS = {
}
}
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="
}
ERROR_MESSAGE = _lt("Something went wrong. Please onboard the journal again.")
_logger = logging.getLogger(__name__)
class AccountJournal(models.Model):
_inherit = 'account.journal'
@ -74,23 +73,34 @@ class AccountJournal(models.Model):
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_certificate_id = fields.Many2one(string="PCSID Certificate", comodel_name="certificate.certificate",
domain=[('is_valid', '=', True)])
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_production_csid_validity = fields.Datetime(related="l10n_sa_production_csid_certificate_id.date_end")
l10n_sa_compliance_csid_certificate_id = fields.Many2one(string="CCSID certificate", comodel_name="certificate.certificate",
domain=[('is_valid', '=', 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="Unique Serial Number automatically filled when the journal is onboarded")
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)")
def _l10n_sa_reset_chain_head_error(self):
"""
Reset the chain head error from the journal's stuck invoices
"""
stuck_invoices = self.env['account.move'].search([
('l10n_sa_edi_chain_head_id', '!=', False),
('journal_id', 'in', self.ids),
])
# We only need to remove blocking errors, so webservices do not need to be triggered
stuck_invoices._retry_edi_documents_error()
# ====== Utility Functions =======
def _l10n_sa_ready_to_submit_einvoices(self):
@ -108,95 +118,20 @@ class AccountJournal(models.Model):
# If the invoice wasn't sent to ZATCA because of a timeout, it will retain its existing chain index
# Make sure there are no opened invoices with the journal's existing sequence
move_ids = self.env['account.move'].search(
[
('journal_id', '=', self.id),
('l10n_sa_chain_index', '!=', 0)
]
)
stuck_moves = [move for move in move_ids if not move._l10n_sa_is_in_chain()]
if stuck_moves:
has_stuck_moves = self.env['account.edi.document'].search([
('move_id.journal_id', '=', self.id),
('move_id.l10n_sa_chain_index', '!=', 0),
('edi_format_id.code', '=', 'sa_zatca'),
('state', '=', 'to_send'),
], limit=1)
if has_stuck_moves:
raise UserError(_("Oops! The journal is stuck. Please submit the pending invoices to ZATCA and try again."))
def _l10n_sa_edi_set_csr_fields(self):
'''
Sets default values for CSR generation fields in Odoo, if their values do not exist
'''
self.ensure_one()
# Avoid unnecessary write calls
if self.l10n_sa_serial_number != str(self.id):
self.l10n_sa_serial_number = self.id
# ====== 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()
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, company_id.name),
# Subject Common Name (Short Code - Journal Name - Company Name)
(NameOID.COMMON_NAME, "%s-%s-%s" % (self.code, self.name, company_id.name)),
# Organization Identifier
(ObjectIdentifier('2.5.4.97'), company_id.vat),
# State/Province Name
(NameOID.STATE_OR_PROVINCE_NAME, company_id.state_id.name),
# Locality Name
(NameOID.LOCALITY_NAME, 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'), company_id.street),
# Industry
x509.NameAttribute(ObjectIdentifier('2.5.4.15'), 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()
return ['l10n_sa_private_key_id', 'vat', 'name', 'city', 'country_id', 'state_id', 'street']
def _l10n_sa_generate_csr(self):
"""
@ -204,26 +139,42 @@ class AccountJournal(models.Model):
"""
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]))
raise UserError(
_(
"Please set the following on %(company_name)s: %(fields)s",
company_name=self.company_id.name,
fields=", ".join(
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()
self.l10n_sa_csr = self.env['certificate.certificate'].sudo()._l10n_sa_get_csr_str(self)
# ====== Certificate Methods =======
@api.depends('l10n_sa_production_csid_json')
def _l10n_sa_compute_production_csid_validity(self):
def _l10n_sa_get_csid_error(self, csid):
"""
Compute the expiration date of the Production certificate
Return a formatted error string if the CSID response has an 'error' or 'errors'
key or doesn't have a 'binarySecurityToken'
"""
for journal in self:
journal.l10n_sa_production_csid_validity = False
if journal.sudo().l10n_sa_production_csid_json:
journal.l10n_sa_production_csid_validity = self._l10n_sa_get_pcsid_validity(
json.loads(journal.sudo().l10n_sa_production_csid_json)
)
error_msg = ""
unknown_error_msg = _("Unknown response returned from ZATCA. Please check the logs.")
if error := csid.get('error'):
error_msg = error
elif errors := csid.get('errors'):
error_msg = " <br/>" + " <br/>- ".join([err['message'] if isinstance(err, dict) else err for err in errors])
elif 'error' in [csid.get('type', "").lower(), csid.get('status', "").lower()]:
error_msg = csid.get('message') or unknown_error_msg
elif not csid.get('binarySecurityToken'):
error_msg = unknown_error_msg
if error_msg:
_logger.warning("Failed to obtain CSID: %s", csid)
return error_msg
def _l10n_sa_reset_certificates(self):
"""
@ -246,13 +197,14 @@ class AccountJournal(models.Model):
# we want to perform sanity checks to ensure that the journal is ready to be onboarded
# If the check fails, we do not want to revoke the existing PCSID because the user might still need it to post hanging invoices
self._l10n_sa_api_onboard_sanity_checks()
self._l10n_sa_edi_set_csr_fields()
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()
ec_private_key_sudo = self.company_id.sudo().l10n_sa_private_key_id
if not ec_private_key_sudo:
ec_private_key_sudo = self.env['certificate.key'].sudo()._generate_ec_private_key(self.company_id, name='CCSID private key')
self.company_id.l10n_sa_private_key_id = ec_private_key_sudo
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)
@ -275,11 +227,18 @@ class AccountJournal(models.Model):
Request a Compliance Cryptographic Stamp Identifier (CCSID) from ZATCA
"""
CCSID_data = self._l10n_sa_api_get_compliance_CSID(otp)
if CCSID_data.get('errors') or CCSID_data.get('error'):
raise UserError(_("Could not obtain Compliance CSID: %s",
CCSID_data['errors'][0]['message'] if CCSID_data.get('errors') else CCSID_data['error']))
if error := self._l10n_sa_get_csid_error(CCSID_data):
raise UserError(_("Please check the details below and onboard the journal again: %s", error))
cert_id = self.env['certificate.certificate'].sudo().create({
'name': 'CCSID Certificate',
'content': b64decode(CCSID_data['binarySecurityToken']),
'private_key_id': self.company_id.sudo().l10n_sa_private_key_id.id,
'company_id': self.company_id.id,
}).id
self.sudo().write({
'l10n_sa_compliance_csid_json': json.dumps(CCSID_data),
'l10n_sa_compliance_csid_certificate_id': cert_id,
'l10n_sa_production_csid_json': False,
'l10n_sa_compliance_checks_passed': False,
})
@ -291,26 +250,30 @@ class AccountJournal(models.Model):
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"))
if not self_sudo.l10n_sa_compliance_csid_json or not self_sudo.l10n_sa_compliance_csid_certificate_id or not self_sudo.l10n_sa_compliance_checks_passed:
raise UserError(str(ERROR_MESSAGE))
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:
validity_time = self_sudo.l10n_sa_production_csid_validity
if zatca_format._l10n_sa_get_zatca_datetime(validity_time) < time_now:
renew = True
else:
raise UserError(_("The Production CSID is still valid. You can only renew it once it has expired."))
raise UserError(_("The Journal is valid until (%s) and can only be renewed upon expiry.", validity_time))
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'])
if error := self._l10n_sa_get_csid_error(PCSID_data):
raise UserError(_("Could not obtain Production CSID: %s", error))
self_sudo.l10n_sa_production_csid_json = json.dumps(PCSID_data)
pcsid_certificate = self_sudo.env['certificate.certificate'].create({
'name': 'PCSID Certificate',
'content': b64decode(PCSID_data['binarySecurityToken']),
})
self.l10n_sa_production_csid_certificate_id = pcsid_certificate
# ====== Compliance Checks =======
@ -323,8 +286,8 @@ class AccountJournal(models.Model):
'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:
fpath = f'l10n_sa_edi/tests/compliance/{file}'
with file_open(fpath, 'rb', filter_ext=('.xml',)) as ip:
compliance_files[file] = ip.read().decode()
return compliance_files
@ -344,38 +307,34 @@ class AccountJournal(models.Model):
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"))
raise UserError(_("Please change the (%s)'s country to Saudi Arabia and try again.", self.company_id.name))
if not self_sudo.l10n_sa_compliance_csid_json or not self_sudo.l10n_sa_compliance_csid_certificate_id:
raise UserError(str(ERROR_MESSAGE))
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)
prepared_xml = self._l10n_sa_prepare_compliance_xml(fname, fval, self_sudo.l10n_sa_compliance_csid_certificate_id, 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))
raise UserError(Markup("<p class='mb-0'>%s</p>") % (str(ERROR_MESSAGE)))
if result['validationResults']['status'] == 'WARNING':
warnings = "".join(Markup("<li><b>%s</b>: %s </li>") % (e['code'], e['message']) for e in result['validationResults']['warningMessages'])
warnings = Markup().join(Markup("<li><b>%(code)s</b>: %(message)s </li>") % e 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)))
raise UserError(Markup("<p class='mb-0'>%s</p>") % (str(ERROR_MESSAGE)))
self.l10n_sa_compliance_checks_passed = True
def _l10n_sa_prepare_compliance_xml(self, xml_name, xml_raw, PCSID, signature):
def _l10n_sa_prepare_compliance_xml(self, xml_name, xml_raw, certificate, 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)
signed_xml = self.env.ref('l10n_sa_edi.edi_sa_zatca')._l10n_sa_sign_xml(xml_content, certificate, 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)
qr_code_str = self.env['account.move']._l10n_sa_get_qr_code(self.company_id, signed_xml, certificate, 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()
@ -459,9 +418,9 @@ class AccountJournal(models.Model):
"""
self.ensure_one()
if not otp:
raise UserError(_("Please, set a valid OTP to be used for Onboarding"))
raise UserError(_("The OTP is invalid. Please try again."))
if not self.l10n_sa_csr:
raise UserError(_("Please, generate a CSR before requesting a CCSID"))
raise UserError(str(ERROR_MESSAGE))
request_data = {
'body': json.dumps({'csr': self.l10n_sa_csr.decode()}),
'header': {'OTP': otp}
@ -557,14 +516,6 @@ class AccountJournal(models.Model):
# ====== 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
@ -581,14 +532,13 @@ class AccountJournal(models.Model):
Get CSIDs required to perform ZATCA api calls, and regenerate them if they need to be regenerated.
"""
self.ensure_one()
if not self.sudo().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.sudo().l10n_sa_production_csid_json)
self_sudo = self.sudo()
if not self_sudo.l10n_sa_production_csid_json or not self_sudo.l10n_sa_production_csid_certificate_id:
raise UserError(str(ERROR_MESSAGE))
certificate = self_sudo.l10n_sa_production_csid_certificate_id
if not certificate.is_valid and self.company_id.l10n_sa_api_mode != 'sandbox':
raise UserError(_("The Journal is not valid anymore. Please Renew it."))
return json.loads(self_sudo.l10n_sa_production_csid_json), certificate.id
# ====== API Helper Methods =======
@ -604,14 +554,13 @@ class AccountJournal(models.Model):
headers={
**self._l10n_sa_api_headers(),
**request_data.get('header')
}, timeout=(30, 30))
}, timeout=30)
request_response.raise_for_status()
except (ValueError, HTTPError) as ex:
# The 400 case means that it is rejected by ZATCA, but we need to update the hash as done for accepted.
# In the 401+ cases, it is like the server is overloaded e.g. and we still need to resend later. We do not
# erase the index chain (excepted) because for ZATCA, one ICV (index chain) needs to correspond to one invoice.
status_code = ex.response.status_code
if status_code not in {400, 409}:
if (status_code := ex.response.status_code) not in {400, 409}:
return {
'error': (Markup("<b>[%s]</b>") % status_code) + _("Server returned an unexpected error: %(error)s",
error=(request_response.text or str(ex))),
@ -677,9 +626,8 @@ class AccountJournal(models.Model):
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.company_id.l10n_sa_private_key_id = self.env['certificate.key']._generate_ec_private_key(self.company_id)
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": "TUlJQ2N6Q0NBaG1nQXdJQkFnSUdBWStWTmxza01Bb0dDQ3FHU000OUJBTUNNQlV4RXpBUkJnTlZCQU1NQ21WSmJuWnZhV05wYm1jd0hoY05NalF3TlRJd01EZzFOVEV6V2hjTk1qa3dOVEU1TWpFd01EQXdXakNCbnpFTE1Ba0dBMVVFQmhNQ1UwRXhFekFSQmdOVkJBc01Dak01T1RrNU9UazVPVGt4RXpBUkJnTlZCQW9NQ2xOQklFTnZiWEJoYm5reEV6QVJCZ05WQkFNTUNsTkJJRU52YlhCaGJua3hHREFXQmdOVkJHRU1Eek01T1RrNU9UazVPVGt3TURBd016RVBNQTBHQTFVRUNBd0dVbWw1WVdSb01TWXdKQVlEVlFRSERCM1lwOW1FMllYWXI5bUsyWWJZcVNEWXA5bUUyWVhaaHRtSTJMSFlxVEJXTUJBR0J5cUdTTTQ5QWdFR0JTdUJCQUFLQTBJQUJOVlB3N0hGNjhUVWtQTkJQb29uT0Y2NnRPMm5IcmxUNlRMcmk3MEpLY1MvYmVMWitoRVE0MmdXdUtYckp5RmxnWm9kUVJzTFQyMEtQZnE0Q3N2YlFJMmpnY3d3Z2Nrd0RBWURWUjBUQVFIL0JBSXdBRENCdUFZRFZSMFJCSUd3TUlHdHBJR3FNSUduTVRRd01nWURWUVFFRENzeExVOWtiMjk4TWkweE5Yd3pMVk5KUkVrekxVTkNUVkJTTFV3eVJEaFlMVXROTUV0T0xWZzBTVk5LTVI4d0hRWUtDWkltaVpQeUxHUUJBUXdQTXprNU9UazVPVGs1T1RBd01EQXpNUTB3Q3dZRFZRUU1EQVF4TVRBd01TOHdMUVlEVlFRYURDWkJiQ0JCYldseUlFMXZhR0Z0YldWa0lFSnBiaUJCWW1SMWJDQkJlbWw2SUZOMGNtVmxkREVPTUF3R0ExVUVEd3dGVDNSb1pYSXdDZ1lJS29aSXpqMEVBd0lEU0FBd1JRSWdTeVhlZExqOUtMVTRUMWFBbVQvL09GZDBGWWxLQnIraFFIeGNDM0c2ajc4Q0lRRGdlNjNsQkVqTU1ETktqTm1pTklaQlBWSnlHRzl5bVJaSHdvUzV5TEQyZXc9PQ==", "secret": "uMpSz85cV0h/e/uqpJ+FaZkdYZ76uoaRYOevGufcup0=", "errors": null}""",

View file

@ -1,14 +1,12 @@
import base64
import uuid
import json
from markupsafe import Markup
from odoo import _, fields, models, api
from odoo.exceptions import UserError
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):
@ -22,14 +20,46 @@ class AccountMove(models.Model):
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",
)
l10n_sa_edi_chain_head_id = fields.Many2one(
'account.move',
string="ZATCA chain stopping move",
copy=False,
readonly=True,
help="Technical field to know if the chain has been stopped by a previous invoice",
)
def _l10n_gcc_get_invoice_title(self):
# DEPRECATED - to be removed in master
# EXTENDS l10n_gcc_invoice
self.ensure_one()
if self.company_id.country_code == 'SA' and self._l10n_sa_is_simplified():
return self.env._('Simplified Tax Invoice')
return super()._l10n_gcc_get_invoice_title()
def _l10n_sa_is_simplified(self):
# DEPRECATED - to be removed in master
"""
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'
return (
self.partner_id.commercial_partner_id.company_type == "person"
if self.partner_id.commercial_partner_id
else self.partner_id.company_type == "person"
)
@api.ondelete(at_uninstall=False)
def _prevent_zatca_rejected_invoice_deletion(self):
# Prevent deletion of ZATCA-rejected invoices in production mode
descr = 'Rejected ZATCA Document not to be deleted - ثيقة ZATCA المرفوضة لا يجوز حذفها'
for move in self:
if move.country_code == "SA" and \
move.company_id.l10n_sa_edi_is_production and \
move.attachment_ids.filtered(lambda a: a.description == descr and a.res_model == 'account.move'):
raise UserError(_("The Invoice(s) are linked to a validated EDI document and cannot be modified according to ZATCA rules"))
@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', 'edi_document_ids',
@ -42,12 +72,12 @@ class AccountMove(models.Model):
if move.country_code == 'SA' and move.move_type in ('out_invoice', 'out_refund') and zatca_document and move.state != 'draft':
qr_code_str = ''
if move._l10n_sa_is_simplified():
x509_cert = json.loads(move.journal_id.sudo().l10n_sa_production_csid_json)['binarySecurityToken']
x509_cert = move.journal_id.l10n_sa_production_csid_certificate_id
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),
qr_code_str = move._l10n_sa_get_qr_code(move.company_id, xml_content, x509_cert,
move.l10n_sa_invoice_signature, True)
qr_code_str = b64encode(qr_code_str).decode()
elif zatca_document.state == 'sent' and zatca_document.sudo().attachment_id.datas:
elif zatca_document.state == 'sent' and zatca_document.attachment_id.datas:
document_xml = zatca_document.attachment_id.with_context(bin_size=False).datas.decode()
root = etree.fromstring(b64decode(document_xml))
qr_node = root.xpath('//*[local-name()="ID"][text()="QR"]/following-sibling::*/*')[0]
@ -68,15 +98,15 @@ class AccountMove(models.Model):
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):
def _l10n_sa_check_billing_reference(self):
"""
Make sure credit/debit notes have a valid reason and reversal reference
Make sure credit/debit notes have a either a reveresed move or debited move or a customer reference
"""
self.ensure_one()
return self.reversed_entry_id or self.ref
return self.debit_origin_id or self.reversed_entry_id or self.ref
@api.model
def _l10n_sa_get_qr_code(self, journal_id, unsigned_xml, x509_cert, signature, is_b2c=False):
def _l10n_sa_get_qr_code(self, company_id, unsigned_xml, certificate, signature, is_b2c=False):
"""
Generate QR code string based on XML content of the Invoice UBL file, X509 Production Certificate
and company info.
@ -98,30 +128,27 @@ class AccountMove(models.Model):
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 and signature:
if invoice_datetime and company_id.vat and certificate and signature:
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_total = float(xpath_ns('//cbc:PayableAmount'))
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())
seller_name_enc = self._l10n_sa_get_qr_code_encoding(1, company_id.display_name.encode())
seller_vat_enc = self._l10n_sa_get_qr_code_encoding(2, company_id.vat.encode())
timestamp_enc = self._l10n_sa_get_qr_code_encoding(3,
invoice_datetime.strftime("%Y-%m-%dT%H:%M:%S").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))
public_key_enc = self._l10n_sa_get_qr_code_encoding(8, base64.b64decode(certificate._get_public_key_bytes(formatting='base64')))
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)
qr_code_str += self._l10n_sa_get_qr_code_encoding(9, base64.b64decode(certificate._get_signature_bytes(formatting='base64')))
return qr_code_str
@ -138,14 +165,23 @@ class AccountMove(models.Model):
def _compute_show_reset_to_draft_button(self):
"""
Override to hide the Reset to Draft button for ZATCA Invoices that have been successfully submitted
in Production mode.
"""
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:
# The "Reset to Draft" button should be hidden in the following cases:
# - Invoice has been successfully submitted in Production mode.
# - The invoice submission encountered a timed out, regardless of the API mode.
if move.l10n_sa_chain_index and (move.company_id.l10n_sa_edi_is_production or not move._l10n_sa_is_in_chain()):
move.show_reset_to_draft_button = False
def button_draft(self):
# OVERRIDE
for move in self:
if move.country_code == "SA" and move.l10n_sa_chain_index and move.company_id.l10n_sa_edi_is_production:
raise UserError(_("The Invoice(s) are linked to a validated EDI document and cannot be modified according to ZATCA rules"))
return super().button_draft()
def _l10n_sa_reset_confirmation_datetime(self):
""" OVERRIDE: we want rejected phase 2 invoices to keep the original confirmation datetime"""
for move in self.filtered(lambda m: m.country_code == 'SA'):
@ -177,9 +213,7 @@ class AccountMove(models.Model):
Save submitted invoice XML hash in case of either Rejection or Acceptance.
"""
self.ensure_one()
if not response_data.get("excepted"):
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, subtitle, content = ("success", _("Invoice Successfully Submitted to ZATCA"), "", "" if (not error or not response_data) else response_data)
bootstrap_cls, title, subtitle, content = ("success", _("Success: Invoice accepted by ZATCA"), "", "" if (not error or not response_data) else response_data)
status_code = response_data.get('status_code')
attachment = False
if error:
@ -194,12 +228,12 @@ class AccountMove(models.Model):
'type': 'binary',
'mimetype': 'application/xml',
})
bootstrap_cls, title = ("danger", _("Invoice was rejected by ZATCA"))
subtitle = _('The invoice was rejected by ZATCA. Please, check the response below:')
bootstrap_cls, title = ("danger", _("Error: Invoice rejected by ZATCA"))
subtitle = _('Please check the details below and retry after addressing them:')
content = response_data['error']
if response_data and response_data.get('validationResults', {}).get('warningMessages'):
bootstrap_cls, title = ("warning", _("Invoice was Accepted by ZATCA (with Warnings)"))
subtitle = _('The invoice was accepted by ZATCA, but returned warnings. Please, check the response below:')
bootstrap_cls, title = ("warning", _("Warning: Invoice accepted by ZATCA with warnings"))
subtitle = _('Please check the details below:')
content = Markup("""<b>%(status_code)s</b>%(errors)s""") % {
"status_code": f"[{status_code}] " if status_code else "",
"errors": Markup("<br/>").join([
@ -207,15 +241,15 @@ class AccountMove(models.Model):
"code": m['code'],
"message": m['message'],
} for m in response_data['validationResults']['warningMessages']
])
]),
}
if response_data.get("error") and response_data.get("excepted"):
bootstrap_cls, title = ("warning", _("Warning: Unable to Retrieve a Response from ZATCA"))
subtitle = _('Unable to retrieve response from ZATCA. Please, check the response below:')
subtitle = _('Please check the details below:')
content = response_data['error']
if status_code == 409:
bootstrap_cls, title = ("warning", _("Warning: Invoice was already successfully reported to ZATCA"))
subtitle = _("This invoice was already successfully reported to ZATCA. Please, check the response below:")
subtitle = _("Please check the details below:")
content = Markup("""<b>%(status_code)s</b>%(errors)s""") % {
"status_code": f"[{status_code}] " if status_code else "",
"errors": Markup("<br/>").join([
@ -225,10 +259,18 @@ class AccountMove(models.Model):
} for m in response_data['validationResults']['errorMessages']
])
}
self.with_context(no_new_invoice=True).message_post(body=Markup("""
if response_data.get("error") and not content:
# if there is an error, but no exception or rejection in the response
# then it is due to an internal error raised. No need to log a note
return
if not response_data.get("excepted"):
self.journal_id.l10n_sa_latest_submission_hash = self.env['account.edi.xml.ubl_21.zatca']._l10n_sa_generate_invoice_xml_hash(xml_content)
self.message_post(body=Markup("""
<div role='alert' class='alert alert-%s'>
<h4 class='alert-heading'>%s</h4>
<p class='mb-0'>
<h4 class='alert-heading my-0'>%s</h4>
<p class='mb-0 mt-1'>
%s
</p>
%s
@ -237,13 +279,23 @@ class AccountMove(models.Model):
</p>
</div>
""") % (bootstrap_cls, title, subtitle, Markup("<hr>") if content else "", content),
attachment_ids=attachment and [attachment.id] or []
attachment_ids=(attachment and [attachment.id]) or [],
)
def _is_l10n_sa_eligibile_invoice(self):
self.ensure_one()
return self.is_invoice() and self.l10n_sa_confirmation_datetime and self.country_code == 'SA'
def _l10n_sa_is_legal(self):
# Extends l10n_sa
# Accounts for both ZATCA phases
# Phase 1: no documents
# Phase 2: checks the state of documents
self.ensure_one()
result = super()._l10n_sa_is_legal()
zatca_document = self.edi_document_ids.filtered(lambda d: d.edi_format_id.code == 'sa_zatca')
return result or (self.company_id.country_id.code == 'SA' and zatca_document and self.edi_state == "sent")
def _get_report_base_filename(self):
"""
Generate the name of the invoice PDF file according to ZATCA business rules:
@ -253,15 +305,10 @@ class AccountMove(models.Model):
return self.with_context(l10n_sa_file_format=False).env['account.edi.xml.ubl_21.zatca']._export_invoice_filename(self)
return super()._get_report_base_filename()
def _get_report_attachment_filename(self):
def _get_invoice_report_filename(self, extension='pdf', report=None):
if self._is_l10n_sa_eligibile_invoice():
return self.with_context(l10n_sa_file_format='pdf').env['account.edi.xml.ubl_21.zatca']._export_invoice_filename(self)
return super()._get_report_attachment_filename()
def _get_report_mail_attachment_filename(self):
if self._is_l10n_sa_eligibile_invoice():
return self.with_context(l10n_sa_file_format=False).env['account.edi.xml.ubl_21.zatca']._export_invoice_filename(self)
return super()._get_report_mail_attachment_filename()
return self.with_context(l10n_sa_file_format=extension).env['account.edi.xml.ubl_21.zatca']._export_invoice_filename(self)
return super()._get_invoice_report_filename(extension, report)
def _l10n_sa_is_in_chain(self):
"""
@ -271,23 +318,39 @@ class AccountMove(models.Model):
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'))
def _get_tax_lines_to_aggregate(self):
def _prepare_tax_lines_for_taxes_computation(self, tax_amls, round_from_tax_lines):
"""
If the final invoice has downpayment lines, we skip the tax correction, as we need to recalculate tax amounts
without taking into account those lines
"""
if self.country_code == 'SA' and not self._is_downpayment() and self.line_ids._get_downpayment_lines():
return self.env['account.move.line']
return super()._get_tax_lines_to_aggregate()
return []
return super()._prepare_tax_lines_for_taxes_computation(tax_amls, round_from_tax_lines)
def _get_l10n_sa_totals(self):
self.ensure_one()
invoice_vals = self.env['account.edi.xml.ubl_21.zatca']._export_invoice_vals(self)
invoice_node = self.env['account.edi.xml.ubl_21.zatca']._get_invoice_node({'invoice': self})
return {
'total_amount': invoice_vals['vals']['legal_monetary_total_vals']['tax_inclusive_amount'],
'total_tax': invoice_vals['vals']['tax_total_vals'][-1]['tax_amount'],
'total_amount': invoice_node['cac:LegalMonetaryTotal']['cbc:TaxInclusiveAmount']['_text'],
'total_tax': invoice_node['cac:TaxTotal'][-1]['cbc:TaxAmount']['_text'],
}
def _retry_edi_documents_error(self):
"""
Hook to reset the chain head error prior to retrying the submission
"""
self.filtered(lambda m: m.country_code == 'SA').write({'l10n_sa_edi_chain_head_id': False})
zatca = self.env.ref('l10n_sa_edi.edi_sa_zatca')
self.filtered(lambda m: m._get_edi_document(zatca))._detach_attachments()
return super()._retry_edi_documents_error()
def action_show_chain_head(self):
"""
Action to show the chain head of the invoice
"""
self.ensure_one()
return self.l10n_sa_edi_chain_head_id._get_records_action(name=_("Chain Head"))
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
@ -306,14 +369,18 @@ class AccountMoveLine(models.Model):
@api.depends('price_subtotal', 'price_total')
def _compute_tax_amount(self):
super()._compute_tax_amount()
taxes_vals_by_move = {}
for record in self:
move = record.move_id
if move.country_code == 'SA':
taxes_vals = taxes_vals_by_move.get(move.id)
if not taxes_vals:
taxes_vals = move._prepare_invoice_aggregated_taxes(
filter_tax_values_to_apply=lambda l, t: not self.env['account.tax'].browse(t['id']).l10n_sa_is_retention
)
taxes_vals_by_move[move.id] = taxes_vals
record.l10n_gcc_invoice_tax_amount = abs(taxes_vals.get('tax_details_per_record', {}).get(record, {}).get('tax_amount_currency', 0))
AccountTax = self.env['account.tax']
for line in self:
if (
line.move_id.country_code == 'SA'
and line.move_id.is_invoice(include_receipts=True)
and line.display_type == 'product'
):
base_line = line.move_id._prepare_product_base_line_for_taxes_computation(line)
AccountTax._add_tax_details_in_base_line(base_line, line.company_id)
AccountTax._round_base_lines_tax_details([base_line], line.company_id)
line.l10n_gcc_invoice_tax_amount = sum(
tax_data['tax_amount_currency']
for tax_data in base_line['tax_details']['taxes_data']
if not tax_data['tax'].l10n_sa_is_retention
)

View file

@ -0,0 +1,26 @@
from odoo import _, api, models
class AccountMoveSend(models.AbstractModel):
_inherit = 'account.move.send'
@api.model
def _is_sa_edi_applicable(self, move):
zatca_document = move.edi_document_ids.filtered(lambda d: d.edi_format_id.code == 'sa_zatca' and d.state == 'to_send')
return move.country_code == 'SA' and move.move_type in ('out_invoice', 'out_refund') and zatca_document and move.state != 'draft'
def _get_all_extra_edis(self) -> dict:
# EXTENDS 'account'
res = super()._get_all_extra_edis()
res.update({'sa_edi': {'label': _("To ZATCA"), 'is_applicable': self._is_sa_edi_applicable}})
return res
def _call_web_service_before_invoice_pdf_render(self, invoices_data):
# EXTENDS 'account'
super()._call_web_service_before_invoice_pdf_render(invoices_data)
to_process = self.env['account.move']
for invoice, invoice_data in invoices_data.items():
if 'sa_edi' in invoice_data['extra_edis']:
to_process |= invoice
to_process.action_process_edi_web_services()

View file

@ -4,12 +4,12 @@ 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-29-7', 'VATEX-SA-29-7 Life insurance services mentioned in Article 29 of the VAT Regulations.'),
('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-2', 'VATEX-SA-34-2 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.'),
@ -39,21 +39,4 @@ class AccountTax(models.Model):
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
raise UserError(_("The tax is unable to be set as Retention as the Amount is greater than or equal to 0."))

View file

@ -0,0 +1,167 @@
import base64
from cryptography import x509
from cryptography.x509 import ObjectIdentifier
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from odoo import _, api, models, service
from odoo.exceptions import UserError
CERT_TEMPLATE_NAME = {
'prod': b'\x0c\x12ZATCA-Code-Signing',
'sandbox': b'\x13\x15PREZATCA-Code-Signing',
'preprod': b'\x13\x15PREZATCA-Code-Signing',
}
MAX_ALLOWED_CSR_VALUE_LENGTH = 64
class CertificateCertificate(models.Model):
_inherit = 'certificate.certificate'
def _l10n_sa_get_issuer_name(self):
self.ensure_one()
cert = x509.load_pem_x509_certificate(base64.b64decode(self.pem_certificate))
return ', '.join([s.rfc4514_string() for s in cert.issuer.rdns[::-1]])
@api.model
def _l10n_sa_get_csr_vals(self, journal):
company_id = journal.company_id
parent_company_id = journal.company_id.parent_id
version_info = service.common.exp_version()
return {
"country_name": {
"value": company_id.country_id.code,
"name": _("Country Name"),
},
"org_unit_name": {
"value": company_id.name if parent_company_id else company_id.vat[:10],
"name": _("Company Name"),
},
"org_name": {
"value": parent_company_id.name if parent_company_id else company_id.name,
"name": _("Parent Company Name") if parent_company_id else _("Company Name"),
},
"common_name": {
"value": f"{journal.code}-{journal.name}-{company_id.name}",
"name": _("Common Name"),
},
"org_id": {
"value": parent_company_id.vat if parent_company_id else company_id.vat,
"name": _("Parent Company VAT") if parent_company_id else _("Company VAT"),
},
"state_name": {
"value": company_id.state_id.name,
"name": _("State/Province Name"),
},
"locality_name": {
"value": company_id.city,
"name": _("Locality Name"),
},
"egs_serial": {
"value": f"1-Odoo|2-{version_info['server_serie']}|3-{journal.id}",
"name": _("Journal Serial Number"),
},
"org_uid": {
"value": company_id.vat,
"name": _("Company VAT"),
},
"invoice_type": {
"value": company_id._l10n_sa_get_csr_invoice_type(),
"name": _("Invoice Type"),
},
"location": {
"value": company_id.street,
"name": _("Street"),
},
"industry": {
"value": company_id.partner_id.industry_id.name or _("Other"),
"name": _("Partner Industry Name"),
},
"cert_tmp": {
"value": CERT_TEMPLATE_NAME[company_id.l10n_sa_api_mode],
"name": _("Certificate Template Name"),
},
}
@api.model
def _l10n_sa_validate_csr_vals(self, journal):
error_fields = set()
for data in self._l10n_sa_get_csr_vals(journal).values():
if len(str(data['value'])) > MAX_ALLOWED_CSR_VALUE_LENGTH:
error_fields.add(data['name'])
if error_fields:
company_fields = [_("Company Name"), _("Parent Company Name")]
company_msg = _("<br/><br/>Once the journal is onboarded, please update the company name to match the one listed on the VAT Registration Certificate.") if any(field in error_fields for field in company_fields) else ""
raise UserError(_(
"Please make sure the following fields are shorter than %(max_length)d characters: %(error_fields_msg)s",
max_length=MAX_ALLOWED_CSR_VALUE_LENGTH,
error_fields_msg=" <br/>- " + " <br/>- ".join(error_fields) + company_msg
))
@api.model
def _l10n_sa_get_csr_str(self, journal):
"""
Return a string representation of a ZATCA compliant CSR that will be sent to the Compliance API in order to get back
a signed X509 certificate
"""
if not journal:
return
builder = x509.CertificateSigningRequestBuilder()
self._l10n_sa_validate_csr_vals(journal)
csr_vals = {key: data['value'] for key, data in self._l10n_sa_get_csr_vals(journal).items()}
subject_names = (
# Country Name
(NameOID.COUNTRY_NAME, csr_vals['country_name']),
# Organization Unit Name
(NameOID.ORGANIZATIONAL_UNIT_NAME, csr_vals['org_unit_name']),
# Organization Name
(NameOID.ORGANIZATION_NAME, csr_vals['org_name']),
# Subject Common Name
(NameOID.COMMON_NAME, csr_vals['common_name']),
# Organization Identifier
(ObjectIdentifier('2.5.4.97'), csr_vals['org_id']),
# State/Province Name
(NameOID.STATE_OR_PROVINCE_NAME, csr_vals['state_name']),
# Locality Name
(NameOID.LOCALITY_NAME, csr_vals['locality_name']),
)
# 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], '%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'), csr_vals['egs_serial']),
# Organisation Identifier (UID)
x509.NameAttribute(NameOID.USER_ID, csr_vals['org_uid']),
# Invoice Type. 4-digit numerical input using 0 & 1
x509.NameAttribute(NameOID.TITLE, csr_vals['invoice_type']),
# Location
x509.NameAttribute(ObjectIdentifier('2.5.4.26'), csr_vals['location']),
# Industry
x509.NameAttribute(ObjectIdentifier('2.5.4.15'), csr_vals['industry']),
]))
])
x509_extensions = (
# Add Certificate template name extension
(x509.UnrecognizedExtension(ObjectIdentifier('1.3.6.1.4.1.311.20.2'),
csr_vals['cert_tmp']), 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 = serialization.load_pem_private_key(base64.b64decode(journal.company_id.l10n_sa_private_key_id.pem_key), password=None)
request = builder.sign(private_key, hashes.SHA256())
return base64.b64encode(request.public_bytes(serialization.Encoding.PEM)).decode()

View file

@ -1,4 +1,4 @@
from odoo import api, models, _
from odoo import _, api, models
from odoo.exceptions import UserError
@ -12,6 +12,35 @@ class IrAttachment(models.Model):
'''
descr = 'Rejected ZATCA Document not to be deleted - ثيقة ZATCA المرفوضة لا يجوز حذفها'
for attach in self.filtered(lambda a: a.description == descr and a.res_model == 'account.move'):
move = self.env['account.move'].browse(attach.res_id)
if move.country_code == "SA":
move = self.env['account.move'].browse(attach.res_id).exists()
if move and move.country_code == "SA":
raise UserError(_("You can't unlink an attachment being an EDI document refused by the government."))
@api.ondelete(at_uninstall=False)
def _unlink_except_validated_pdf_invoices(self):
'''
Prevents unlinking of invoice pdfs linked to an invoice
where the pdf attachment was created after or at the same time as the edi_documents last write date.
'''
attachments_to_check = self.filtered(
lambda attachment: attachment.res_model == "account.move"
and attachment.res_field == "invoice_pdf_report_file"
)
res = self.env["account.edi.document"]._read_group(
domain=[("move_id", "in", attachments_to_check.mapped("res_id")), ("state", "=", "sent"), ("edi_format_id.code", "=", "sa_zatca")],
aggregates=["write_date:min"],
groupby=["move_id"],
)
edi_documents = {doc[0].id: doc[1] for doc in res}
restricted_attachments = self.env["ir.attachment"]
for attachment in attachments_to_check:
if (document_date := edi_documents.get(attachment.res_id)) and attachment.create_date >= document_date:
restricted_attachments += attachment
if restricted_attachments:
raise UserError(_(
"Oops! The invoice PDF(s) are linked to a validated EDI document and cannot be deleted according to ZATCA rules: %s",
", ".join(restricted_attachments.mapped("name"))))
def _get_posted_pdf_moves_to_check(self):
# Extends l10n_sa: to bypass the unlink check in l10n_sa for posted moves
return super()._get_posted_pdf_moves_to_check().filtered(lambda rec: not rec.edi_state)

View file

@ -1,32 +1,12 @@
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,
l10n_sa_private_key_id = fields.Many2one(string="ZATCA Private key", comodel_name='certificate.key', copy=False, domain=[('public', '=', False)],
help="The private key used to generate the CSR and obtain certificates",)
l10n_sa_api_mode = fields.Selection(
@ -39,19 +19,26 @@ class ResCompany(models.Model):
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)
l10n_sa_edi_additional_identification_scheme = fields.Selection(
related='partner_id.l10n_sa_edi_additional_identification_scheme', readonly=False)
l10n_sa_edi_additional_identification_number = fields.Char(
related='partner_id.l10n_sa_edi_additional_identification_number', readonly=False)
l10n_sa_edi_is_production = fields.Boolean(string="Is Production", copy=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)])
# Prevent API mode change from 'Production' if any invoice was submitted to ZATCA in Production mode.
if company.l10n_sa_edi_is_production:
raise UserError(_("ZATCA API Mode cannot be changed after an invoice has been successfully submitted under the Production Mode."))
journals = self.env['account.journal'].search(self.env['account.journal']._check_company_domain(company))
journals._l10n_sa_reset_certificates()
journals.l10n_sa_latest_submission_hash = False
api_mode = dict(self._fields['l10n_sa_api_mode'].selection).get(vals['l10n_sa_api_mode'])
for journal in journals.filtered(lambda j: j.type == 'sale'):
journal.message_post(body=_("ZATCA API Mode changed to %s", api_mode))
return super().write(vals)
def _get_company_address_field_names(self):

View file

@ -11,4 +11,9 @@ class ResConfigSettings(models.TransientModel):
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)
record.company_informations += _(
'\nBuilding Number: %(building_number)s, Plot Identification: %(plot_identification)s\nNeighborhood: %(neighborhood)s',
building_number=self.company_id.l10n_sa_edi_building_number,
plot_identification=self.company_id.l10n_sa_edi_plot_identification,
neighborhood=self.company_id.street2,
)

View file

@ -7,7 +7,7 @@ class ResPartner(models.Model):
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([
l10n_sa_edi_additional_identification_scheme = fields.Selection([
('TIN', 'Tax Identification Number'),
('CRN', 'Commercial Registration Number'),
('MOM', 'Momra License'),
@ -19,17 +19,16 @@ class ResPartner(models.Model):
('IQA', 'Iqama Number'),
('PAS', 'Passport ID'),
('OTH', 'Other ID')
], default="OTH", string="Identification Scheme", help="Additional Identification scheme for Seller/Buyer")
], default="OTH", string="Identification Scheme", help="Additional Identification Scheme for the Seller/Buyer")
l10n_sa_additional_identification_number = fields.Char("Identification Number (SA)",
help="Additional Identification Number for Seller/Buyer")
l10n_sa_edi_additional_identification_number = fields.Char("Identification Number (SA)", help="Additional Identification Number for the 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']
'l10n_sa_edi_additional_identification_scheme',
'l10n_sa_edi_additional_identification_number']
def _address_fields(self):
return super()._address_fields() + ['l10n_sa_edi_building_number',

View file

@ -3,3 +3,4 @@
from . import common
from . import test_edi_zatca
from . import test_invoice

View file

@ -1,248 +1,363 @@
# coding: utf-8
from datetime import datetime
import json
from base64 import b64decode
from odoo import Command
from odoo.tests import tagged
from odoo.tests.common import new_test_user
from odoo.addons.account_edi.tests.common import AccountEdiTestCommon
@tagged('post_install_l10n', '-at_install', 'post_install')
class TestSaEdiCommon(AccountEdiTestCommon):
"""
Base test class for Saudi Arabia EDI functionality.
Sets up test data for ZATCA (Saudi tax authority) compliance testing including:
- Company with Saudi-specific fields
- Partners (company and individual)
- Products and taxes
- XPath templates for XML comparison
"""
@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
@AccountEdiTestCommon.setup_edi_format('l10n_sa_edi.edi_sa_zatca')
@AccountEdiTestCommon.setup_chart_template('sa')
@AccountEdiTestCommon.setup_country('sa')
def setUpClass(cls):
super().setUpClass()
# Setup frequently used references
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': '+1 870-931-0505',
cls.saudi_arabia = cls.env.ref('base.sa')
cls.riyadh = cls._get_or_create_state('Riyadh', 'RUH', cls.saudi_arabia)
# Setup test data
cls._setup_company()
cls._setup_branches()
cls._setup_partners()
cls._setup_products()
cls._setup_taxes()
cls._setup_journal()
cls._setup_xpath_templates()
@classmethod
def _get_company_vals(cls, defaults=None):
return {
'name': 'SA Company Test',
'email': 'info@company.saexample.com',
'phone': '+966 51 234 5678',
'vat': '311111111111113',
# Address fields
'street': 'Al Amir Mohammed Bin Abdul Aziz Street',
'street2': 'Testomania',
'city': 'المدينة المنورة',
'zip': '42317',
'country_id': cls.saudi_arabia.id,
'state_id': cls.riyadh.id,
# Saudi-specific fields
'l10n_sa_edi_building_number': '1234',
'l10n_sa_edi_plot_identification': '1234',
'l10n_sa_edi_additional_identification_number': '2525252525252',
'l10n_sa_edi_additional_identification_scheme': 'CRN', # Commercial Registration Number
**(defaults or {})
}
@classmethod
def _setup_company(cls):
"""Configure the test company with Saudi Arabia specific settings."""
cls.company.write(cls._get_company_vals())
@classmethod
def _setup_branches(cls):
vals = cls._get_company_vals({"name": "SA Branch", "parent_id": cls.company.id})
cls.sa_branch = cls._create_company(**vals)
@classmethod
def _setup_partners(cls):
"""Create test partners for different invoice types."""
# Standard invoice partner (company)
cls.partner_sa = cls._create_saudi_company_partner()
# Simplified invoice partner (individual)
cls.partner_sa_simplified = cls._create_saudi_individual_partner()
@classmethod
def _create_saudi_company_partner(cls):
"""Create a Saudi company partner with full ZATCA requirements."""
return cls.env['res.partner'].create({
'name': 'Saud Ahmed',
'ref': 'Saudi Aramco',
'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',
# Contact info
'email': 'saudi.aramco@example.com',
'phone': '+966556666666',
# Tax info
'vat': '311111111111113',
'l10n_sa_edi_additional_identification_scheme': 'CRN',
'l10n_sa_edi_additional_identification_number': '353535353535353',
# Address
'street': '4557 King Salman St',
'street2': 'Neighbor!',
'city': 'Riyadh',
'zip': '94538',
'state_id': cls.riyadh.id,
'country_id': cls.saudi_arabia.id,
# Saudi-specific address fields
'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({
@classmethod
def _create_saudi_individual_partner(cls):
"""Create a Saudi individual partner for simplified invoices."""
return 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',
'country_id': cls.saudi_arabia.id,
'state_id': cls.riyadh.id,
# Simplified invoices use different ID schemes
'l10n_sa_edi_additional_identification_scheme': 'MOM', # Momra License
'l10n_sa_edi_additional_identification_number': '3123123213131',
})
# 15% tax
cls.tax_15 = cls.env['account.tax'].search([('company_id', '=', cls.company.id), ('name', '=', 'Sales Tax 15%')])
@classmethod
def _setup_products(cls):
"""Create test products."""
cls.product_a = cls._create_product(name='Product A', standard_price=320.0, default_code='P0001')
cls.product_b = cls._create_product(name='Product B', standard_price=15.8, default_code='P0002')
cls.product_burger = cls._create_product(name='Burger', standard_price=265.0)
# 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',
})
@classmethod
def _setup_taxes(cls):
"""Setup tax references."""
# Standard 15% VAT in Saudi Arabia
cls.tax_15 = cls.env['account.tax'].search([
('company_id', '=', cls.company.id),
('amount', '=', 15.0)
], limit=1)
cls.product_burger = cls.env['product.product'].create({
'name': 'Burger',
'uom_id': cls.env.ref('uom.product_uom_unit').id,
'standard_price': 265.00,
})
@classmethod
def _setup_journal(cls):
"""Setup and configure the sales journal."""
cls.customer_invoice_journal = cls.env['account.journal'].search([
('company_id', '=', cls.company.id),
('type', '=', 'sale')
], limit=1)
# Load ZATCA demo data (certificates, etc.)
cls.customer_invoice_journal._l10n_sa_load_edi_demo_data()
PCSID_Data = json.loads(cls.customer_invoice_journal.l10n_sa_production_csid_json)
pcsid_certificate = cls.env['certificate.certificate'].create({
'name': 'PCSID Certificate',
'content': b64decode(PCSID_Data['binarySecurityToken']),
})
cls.customer_invoice_journal.l10n_sa_production_csid_certificate_id = pcsid_certificate
@classmethod
def _setup_xpath_templates(cls):
"""
Setup XPath templates for XML testing.
These remove or replace dynamic elements (IDs, UUIDs) that change
between test runs to allow XML comparison.
"""
cls.remove_ubl_extensions_xpath = '''<xpath expr="//*[local-name()='UBLExtensions']" position="replace"/>'''
cls.invoice_applied_xpath = '''
# Common replacements for all document types
common_replacements = '''
<xpath expr="(//*[local-name()='Invoice']/*[local-name()='ID'])[1]" position="replace">
<ID>___ignore___</ID>
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:ID>
</xpath>
<xpath expr="(//*[local-name()='Invoice']/*[local-name()='UUID'])[1]" position="replace">
<UUID>___ignore___</UUID>
<cbc:UUID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:UUID>
</xpath>
<xpath expr="(//*[local-name()='Contact']/*[local-name()='ID'])[1]" position="replace">
<ID>___ignore___</ID>
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:ID>
</xpath>
<xpath expr="(//*[local-name()='Contact']/*[local-name()='ID'])[2]" position="replace">
<ID>___ignore___</ID>
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:ID>
</xpath>
'''
# Invoice-specific replacements
cls.invoice_applied_xpath = common_replacements + '''
<xpath expr="//*[local-name()='PaymentMeans']/*[local-name()='InstructionID']" position="replace">
<InstructionID>___ignore___</InstructionID>
<cbc:InstructionID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:InstructionID>
</xpath>
<xpath expr="(//*[local-name()='PaymentMeans']/*[local-name()='PaymentID'])" position="replace">
<PaymentID>___ignore___</PaymentID>
<cbc:PaymentID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:PaymentID>
</xpath>
<xpath expr="//*[local-name()='InvoiceLine']/*[local-name()='ID']" position="replace">
<ID>___ignore___</ID>
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc: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>
# Credit note specific replacements
cls.credit_note_applied_xpath = common_replacements + '''
<xpath expr="(//*[local-name()='InvoiceDocumentReference']/*[local-name()='ID'])[1]" position="replace">
<ID>___ignore___</ID>
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:ID>
</xpath>
<xpath expr="(//*[local-name()='PaymentMeans']/*[local-name()='InstructionNote'])" position="replace">
<InstructionNote>___ignore___</InstructionNote>
<cbc:InstructionNote xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:InstructionNote>
</xpath>
<xpath expr="(//*[local-name()='PaymentMeans']/*[local-name()='PaymentID'])" position="replace">
<PaymentID>___ignore___</PaymentID>
<cbc:PaymentID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:PaymentID>
</xpath>
<xpath expr="//*[local-name()='InvoiceLine']/*[local-name()='ID']" position="replace">
<ID>___ignore___</ID>
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc: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>
'''
cls.user_saudi = new_test_user(cls.env, 'xav', email='em@il.com', notification_type='inbox', groups='account.group_account_invoice', tz='Asia/Riyadh')
# Debit note specific replacements
cls.debit_note_applied_xpath = common_replacements + '''
<xpath expr="(//*[local-name()='InvoiceDocumentReference']/*[local-name()='ID'])[1]" position="replace">
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:ID>
</xpath>
<xpath expr="//*[local-name()='InvoiceLine']/*[local-name()='ID']" position="replace">
<cbc:ID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:ID>
</xpath>
<xpath expr="//*[local-name()='PaymentMeans']/*[local-name()='InstructionID']" position="replace">
<cbc:InstructionID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:InstructionID>
</xpath>
<xpath expr="(//*[local-name()='PaymentMeans']/*[local-name()='PaymentID'])" position="replace">
<cbc:PaymentID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:PaymentID>
</xpath>
<xpath expr="(//*[local-name()='PaymentMeans']/*[local-name()='InstructionNote'])" position="replace">
<cbc:InstructionNote xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:InstructionNote>
</xpath>
'''
def _create_invoice(self, **kwargs):
@classmethod
def _get_or_create_state(cls, name, code, country):
"""Ensure a state exists for the given country."""
state = cls.env['res.country.state'].search([
('code', '=', code),
('country_id', '=', country.id)
], limit=1)
if not state:
state = cls.env['res.country.state'].create({
'name': name,
'code': code,
'country_id': country.id
})
return state
# Helper methods for creating documents
def _create_test_invoice(
self,
name="",
move_type="out_invoice",
company_id=None,
partner_id=None,
invoice_date='2025-01-01',
invoice_date_due='2025-01-01',
currency_id=None,
invoice_line_ids=[]):
"""
Create a draft invoice with the given parameters.
"""
def _create_invoice_line(line):
vals = {
'price_unit': line.get('price_unit', 0.0),
'quantity': line.get('quantity', 1),
'tax_ids': line.get('tax_ids', []),
}
if product_id := line.get('product_id'):
vals['product_id'] = product_id
if name := line.get('name'):
vals['name'] = name
return Command.create(vals)
vals = {
'name': kwargs['name'],
'move_type': 'out_invoice',
'company_id': self.company.id,
'partner_id': kwargs['partner_id'].id,
'invoice_date': kwargs['date'],
'invoice_date_due': kwargs['date_due'],
'currency_id': self.company.currency_id.id,
'invoice_line_ids': [Command.create({
'product_id': kwargs['product_id'].id,
'price_unit': kwargs['price'],
'quantity': kwargs.get('quantity', 1.0),
'tax_ids': [Command.set(self.tax_15.ids)],
}),
'name': name,
'move_type': move_type,
'company_id': (company_id or self.company).id,
'partner_id': partner_id.id,
'invoice_date': invoice_date,
'invoice_date_due': invoice_date_due,
'currency_id': (currency_id or self.company.currency_id).id,
'invoice_line_ids': [
_create_invoice_line(line) for line in invoice_line_ids
],
}
user = kwargs.get('user') or self.env.user
move = self.env['account.move'].with_user(user.id).create(vals)
move.state = 'posted'
move.l10n_sa_confirmation_datetime = datetime.now()
# move.payment_reference = move.name
return move
return self.env['account.move'].create(vals)
def _create_debit_note(self, **kwargs):
invoice = self._create_invoice(**kwargs)
def _create_debit_note(
self,
name="",
move_type="out_invoice",
company_id=None,
partner_id=None,
invoice_date='2025-01-01',
invoice_date_due='2025-01-01',
currency_id=None,
invoice_line_ids=[],
reason="BR-KSA-17-reason-5"):
"""
Create a draft debit note from the given invoice values.
"""
# Create and post the original invoice
invoice = self._create_test_invoice(
name=name,
move_type=move_type,
company_id=company_id,
partner_id=partner_id,
invoice_date=invoice_date,
invoice_date_due=invoice_date_due,
currency_id=currency_id,
invoice_line_ids=invoice_line_ids)
invoice.action_post()
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'})
# Create debit note via wizard
debit_note_wizard = self.env['account.debit.note'].with_context({
'active_ids': [invoice.id],
'active_model': 'account.move',
'default_copy_lines': True
}).create({
'l10n_sa_reason': reason,
})
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,
return self.env['account.move'].browse(res.get('res_id', []))
def _create_credit_note(
self,
name="",
move_type="out_invoice",
company_id=None,
partner_id=None,
invoice_date='2025-01-01',
invoice_date_due='2025-01-01',
currency_id=None,
invoice_line_ids=[],
reason='BR-KSA-17-reason-5'):
"""
Create a draft credit note from the given invoice values.
"""
# Create and post the original invoice
invoice = self._create_test_invoice(
name=name,
move_type=move_type,
company_id=company_id,
partner_id=partner_id,
invoice_date=invoice_date,
invoice_date_due=invoice_date_due,
currency_id=currency_id,
invoice_line_ids=invoice_line_ids)
invoice.action_post()
# Create credit note via reversal wizard
move_reversal = self.env['account.move.reversal'].with_context({
'active_model': 'account.move',
'active_ids': invoice.ids
}).create({
'l10n_sa_reason': reason,
'journal_id': invoice.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
return self.env['account.move'].browse(reversal['res_id'])

View file

@ -9,9 +9,6 @@
<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>
@ -53,7 +50,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -70,7 +67,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -91,7 +88,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -116,31 +113,17 @@
</cac:PartyName>
<cac:PostalAddress>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</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>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>

View file

@ -9,9 +9,6 @@
<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>
@ -53,7 +50,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -70,7 +67,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -91,7 +88,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -116,31 +113,17 @@
</cac:PartyName>
<cac:PostalAddress>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</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>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>

View file

@ -45,7 +45,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -62,7 +62,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -83,7 +83,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -108,31 +108,17 @@
</cac:PartyName>
<cac:PostalAddress>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</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>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>

View file

@ -8,13 +8,10 @@
<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:InvoiceTypeCode name="0100000">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>
<cbc:BuyerReference>Saudi Aramco</cbc:BuyerReference>
<cac:BillingReference>
<cac:InvoiceDocumentReference>
<cbc:ID>test</cbc:ID>
@ -47,7 +44,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -64,7 +61,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -85,7 +82,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -102,45 +99,70 @@
</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>
<cbc:Name>Saud Ahmed</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>4557 De Silva St</cbc:StreetName>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Fremont</cbc:CityName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>California</cbc:CountrySubentity>
<cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>US</cbc:IdentificationCode>
<cbc:Name>United States</cbc:Name>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Chichi Lboukla</cbc:RegistrationName>
<cac:PartyTaxScheme>
<cbc:RegistrationName>Saud Ahmed</cbc:RegistrationName>
<cbc:CompanyID>311111111111113</cbc:CompanyID>
<cac:RegistrationAddress>
<cbc:StreetName>4557 De Silva St</cbc:StreetName>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Fremont</cbc:CityName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>California</cbc:CountrySubentity>
<cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>US</cbc:IdentificationCode>
<cbc:Name>United States</cbc:Name>
<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>Saud Ahmed</cbc:RegistrationName>
<cbc:CompanyID>311111111111113</cbc:CompanyID>
<cac:RegistrationAddress>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</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>340</cbc:ID>
<cbc:Name>Chichi Lboukla</cbc:Name>
<cbc:Telephone>+18709310505</cbc:Telephone>
<cbc:ElectronicMail>azure.Interior24@example.com</cbc:ElectronicMail>
<cbc:Name>Saud Ahmed</cbc:Name>
<cbc:Telephone>+966556666666</cbc:Telephone>
<cbc:ElectronicMail>saudi.aramco@example.com</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingCustomerParty>

View file

@ -8,13 +8,10 @@
<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:InvoiceTypeCode name="0100000">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>
<cbc:BuyerReference>Saudi Aramco</cbc:BuyerReference>
<cac:BillingReference>
<cac:InvoiceDocumentReference>
<cbc:ID>INV/2022/00014</cbc:ID>
@ -47,7 +44,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -64,7 +61,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -85,7 +82,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -102,45 +99,70 @@
</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>
<cbc:Name>Saud Ahmed</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>4557 De Silva St</cbc:StreetName>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Fremont</cbc:CityName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>California</cbc:CountrySubentity>
<cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>US</cbc:IdentificationCode>
<cbc:Name>United States</cbc:Name>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Chichi Lboukla</cbc:RegistrationName>
<cac:PartyTaxScheme>
<cbc:RegistrationName>Saud Ahmed</cbc:RegistrationName>
<cbc:CompanyID>311111111111113</cbc:CompanyID>
<cac:RegistrationAddress>
<cbc:StreetName>4557 De Silva St</cbc:StreetName>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Fremont</cbc:CityName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>California</cbc:CountrySubentity>
<cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>US</cbc:IdentificationCode>
<cbc:Name>United States</cbc:Name>
<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>Saud Ahmed</cbc:RegistrationName>
<cbc:CompanyID>311111111111113</cbc:CompanyID>
<cac:RegistrationAddress>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</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>550</cbc:ID>
<cbc:Name>Chichi Lboukla</cbc:Name>
<cbc:Telephone>+18709310505</cbc:Telephone>
<cbc:ElectronicMail>azure.Interior24@example.com</cbc:ElectronicMail>
<cbc:Name>Saud Ahmed</cbc:Name>
<cbc:Telephone>+966556666666</cbc:Telephone>
<cbc:ElectronicMail>saudi.aramco@example.com</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingCustomerParty>

View file

@ -8,10 +8,10 @@
<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:InvoiceTypeCode name="0100000">388</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>SAR</cbc:DocumentCurrencyCode>
<cbc:TaxCurrencyCode>SAR</cbc:TaxCurrencyCode>
<cbc:BuyerReference>Azure Interior</cbc:BuyerReference>
<cbc:BuyerReference>Saudi Aramco</cbc:BuyerReference>
<cac:AdditionalDocumentReference>
<cbc:ID>PIH</cbc:ID>
<cac:Attachment>
@ -38,7 +38,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -55,7 +55,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -76,7 +76,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -94,47 +94,69 @@
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="CRN">US12345677</cbc:ID>
<cbc:ID schemeID="CRN">353535353535353</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>Chichi Lboukla</cbc:Name>
<cbc:Name>Saud Ahmed</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>4557 De Silva St</cbc:StreetName>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Fremont</cbc:CityName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>California</cbc:CountrySubentity>
<cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>US</cbc:IdentificationCode>
<cbc:Name>United States</cbc:Name>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Chichi Lboukla</cbc:RegistrationName>
<cac:PartyTaxScheme>
<cbc:RegistrationName>Saud Ahmed</cbc:RegistrationName>
<cbc:CompanyID>311111111111113</cbc:CompanyID>
<cac:RegistrationAddress>
<cbc:StreetName>4557 De Silva St</cbc:StreetName>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Fremont</cbc:CityName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>California</cbc:CountrySubentity>
<cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>US</cbc:IdentificationCode>
<cbc:Name>United States</cbc:Name>
<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>Saud Ahmed</cbc:RegistrationName>
<cbc:CompanyID>311111111111113</cbc:CompanyID>
<cac:RegistrationAddress>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</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>42</cbc:ID>
<cbc:Name>Chichi Lboukla</cbc:Name>
<cbc:Telephone>+18709310505</cbc:Telephone>
<cbc:ElectronicMail>azure.Interior24@example.com</cbc:ElectronicMail>
<cbc:Name>Saud Ahmed</cbc:Name>
<cbc:Telephone>+966556666666</cbc:Telephone>
<cbc:ElectronicMail>saudi.aramco@example.com</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingCustomerParty>

View file

@ -1,245 +1,575 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from datetime import datetime
from freezegun import freeze_time
import logging
from lxml import etree
from pytz import timezone
from odoo import Command
from odoo.exceptions import ValidationError, UserError
from odoo.tests import tagged
from odoo.tools import misc
from .common import TestSaEdiCommon
_logger = logging.getLogger(__name__)
from odoo.addons.l10n_sa_edi.tests.common import TestSaEdiCommon
@tagged('post_install_l10n', '-at_install', 'post_install')
class TestEdiZatca(TestSaEdiCommon):
# """Test ZATCA EDI compliance for Saudi Arabia."""
def _test_document_generation(self, test_file_path, expected_xpath, freeze_time_at, additional_xpath='', document_type=False, move=False, move_data=False):
"""
Common helper to test document generation against expected XML.
"""
with freeze_time(freeze_time_at):
# Load expected XML
expected_xml = misc.file_open(test_file_path, 'rb').read()
expected_tree = self.get_xml_tree_from_string(expected_xml)
expected_tree = self.with_applied_xpath(expected_tree, expected_xpath)
creation_handlers = {
"invoice": self._create_test_invoice,
"credit_note": self._create_credit_note,
"debit_note": self._create_debit_note,
}
if additional_xpath:
expected_tree = self.with_applied_xpath(expected_tree, additional_xpath)
if move:
final_move = move
elif move_data and document_type in creation_handlers:
final_move = creation_handlers[document_type](**move_data)
else:
raise ValidationError("Either move or document_type + move_data need to be given")
# Generate ZATCA XML
if final_move.state != 'posted':
final_move.action_post()
final_move._l10n_sa_generate_unsigned_data()
generated_file = self.env['account.edi.format']._l10n_sa_generate_zatca_template(final_move)
current_tree = self.get_xml_tree_from_string(generated_file)
current_tree = self.with_applied_xpath(current_tree, self.remove_ubl_extensions_xpath)
# Assert
self.assertXmlTreeEqual(current_tree, expected_tree)
def testCreditNoteSimplified(self):
"""Test simplified credit note generation."""
move_data = {
'name': 'INV/2023/00034',
'invoice_date': '2023-03-10',
'invoice_date_due': '2023-03-10',
'partner_id': self.partner_sa_simplified,
'invoice_line_ids': [{
'product_id': self.product_burger.id,
'price_unit': self.product_burger.standard_price,
'quantity': 3,
'tax_ids': self.tax_15.ids,
}]
}
self._test_document_generation(
document_type='credit_note',
test_file_path='l10n_sa_edi/tests/compliance/simplified/credit.xml',
expected_xpath=self.credit_note_applied_xpath,
move_data=move_data,
freeze_time_at=datetime(2023, 3, 10, 14, 59, 38, tzinfo=timezone('Etc/GMT-3'))
)
def testCreditNoteStandard(self):
"""Test standard credit note generation."""
move_data = {
'name': 'INV/2022/00014',
'invoice_date': '2022-09-05',
'invoice_date_due': '2022-09-22',
'partner_id': self.partner_sa,
'invoice_line_ids': [{
'product_id': self.product_a.id,
'price_unit': self.product_a.standard_price,
'tax_ids': self.tax_15.ids,
}]
}
additional_xpath = '''
<xpath expr="(//*[local-name()='AdditionalDocumentReference']/*[local-name()='UUID'])[1]" position="replace">
<cbc:UUID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:UUID>
</xpath>
'''
self._test_document_generation(
document_type='credit_note',
test_file_path='l10n_sa_edi/tests/compliance/standard/credit.xml',
expected_xpath=self.credit_note_applied_xpath,
move_data=move_data,
freeze_time_at=datetime(2022, 9, 5, 9, 39, 15, tzinfo=timezone('Etc/GMT-3')),
additional_xpath=additional_xpath
)
def testDebitNoteSimplified(self):
"""Test simplified debit note generation."""
move_data = {
'name': 'INV/2023/00034',
'invoice_date': '2023-03-10',
'invoice_date_due': '2023-03-10',
'partner_id': self.partner_sa_simplified,
'invoice_line_ids': [{
'product_id': self.product_burger.id,
'price_unit': self.product_burger.standard_price,
'quantity': 2,
'tax_ids': self.tax_15.ids,
}]
}
self._test_document_generation(
document_type='debit_note',
test_file_path='l10n_sa_edi/tests/compliance/simplified/debit.xml',
expected_xpath=self.debit_note_applied_xpath,
move_data=move_data,
freeze_time_at=datetime(2023, 3, 10, 15, 1, 46, tzinfo=timezone('Etc/GMT-3'))
)
def testDebitNoteStandard(self):
"""Test standard debit note generation."""
move_data = {
'name': 'INV/2022/00001',
'invoice_date': '2022-09-05',
'invoice_date_due': '2022-09-22',
'partner_id': self.partner_sa,
'invoice_line_ids': [{
'product_id': self.product_b.id,
'price_unit': self.product_b.standard_price,
'tax_ids': self.tax_15.ids,
}]
}
additional_xpath = '''
<xpath expr="(//*[local-name()='AdditionalDocumentReference']/*[local-name()='UUID'])[1]" position="replace">
<cbc:UUID xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">___ignore___</cbc:UUID>
</xpath>
'''
self._test_document_generation(
document_type='debit_note',
test_file_path='l10n_sa_edi/tests/compliance/standard/debit.xml',
expected_xpath=self.debit_note_applied_xpath,
move_data=move_data,
freeze_time_at=datetime(2022, 9, 5, 9, 45, 27, tzinfo=timezone('Etc/GMT-3')),
additional_xpath=additional_xpath
)
def testInvoiceSimplified(self):
"""Test simplified invoice generation."""
move_data = {
'name': 'INV/2023/00034',
'invoice_date': '2023-03-10',
'invoice_date_due': '2023-03-10',
'partner_id': self.partner_sa_simplified,
'invoice_line_ids': [{
'product_id': self.product_burger.id,
'price_unit': self.product_burger.standard_price,
'quantity': 3,
'tax_ids': self.tax_15.ids,
}]
}
self._test_document_generation(
document_type='invoice',
test_file_path='l10n_sa_edi/tests/compliance/simplified/invoice.xml',
expected_xpath=self.invoice_applied_xpath,
move_data=move_data,
freeze_time_at=datetime(2023, 3, 10, 14, 56, 55, tzinfo=timezone('Etc/GMT-3'))
)
def testInvoiceStandard(self):
"""Test standard invoice generation."""
move_data = {
'name': 'INV/2022/00014',
'invoice_date': '2022-09-05',
'invoice_date_due': '2022-09-22',
'partner_id': self.partner_sa,
'invoice_line_ids': [{
'product_id': self.product_a.id,
'price_unit': self.product_a.standard_price,
'tax_ids': self.tax_15.ids,
}]
}
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)
self.partner_us.vat = 'US12345677'
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)
self._test_document_generation(
document_type='invoice',
test_file_path='l10n_sa_edi/tests/compliance/standard/invoice.xml',
expected_xpath=self.invoice_applied_xpath,
move_data=move_data,
freeze_time_at=datetime(2022, 9, 5, 8, 20, 2, tzinfo=timezone('Etc/GMT-3'))
)
def testInvoiceWithDownpayment(self):
"""Test invoice generation with downpayment scenarios."""
if 'sale' not in self.env["ir.module.module"]._installed():
self.skipTest("Sale module is not installed")
self.env.user.group_ids += self.env.ref('sales_team.group_sale_salesman')
def test_generated_file(move, test_file, xpath_to_apply):
move.write({
'invoice_date': '2022-09-05',
'invoice_date_due': '2022-09-22',
'state': 'posted',
'l10n_sa_confirmation_datetime': datetime.now(),
})
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)
freeze = datetime(2022, 9, 5, 8, 20, 2, tzinfo=timezone('Etc/GMT-3'))
expected_file = misc.file_open(f'l10n_sa_edi/tests/test_files/{test_file}.xml', 'rb').read()
expected_tree = self.get_xml_tree_from_string(expected_file)
expected_tree = self.with_applied_xpath(expected_tree, xpath_to_apply)
self.assertXmlTreeEqual(current_tree, expected_tree)
retention_tax = self.env['account.tax'].create({
'l10n_sa_is_retention': True,
'name': 'Retention Tax',
'amount_type': 'percent',
'amount': -5.0,
# Helper to test generated files
saudi_pricelist = self.env['product.pricelist'].create({
'name': 'SAR',
'currency_id': self.env.ref('base.SAR').id
})
with freeze_time(datetime(year=2022, month=9, day=5, hour=8, minute=20, second=2, tzinfo=timezone('Etc/GMT-3'))):
self.partner_us.vat = 'US12345677'
pricelist = self.env['product.pricelist'].create({'name': 'SAR', 'currency_id': self.env.ref('base.SAR').id})
sale_order = self.env['sale.order'].create({
'partner_id': self.partner_us.id,
'pricelist_id': pricelist.id,
with freeze_time(freeze):
sale_order = self.env['sale.order'].sudo().create({
'partner_id': self.partner_sa.id,
'pricelist_id': saudi_pricelist.id,
'order_line': [
Command.create({
'product_id': self.product_a.id,
'price_unit': 1000,
'product_uom_qty': 1,
'tax_id': [Command.set((self.tax_15 + retention_tax).ids)],
'tax_ids': [Command.set(self.tax_15.ids)],
})
]
})
}).sudo(False)
sale_order.action_confirm()
# Context for wizards
context = {
'active_model': 'sale.order',
'active_ids': [sale_order.id],
'active_id': sale_order.id,
'default_journal_id': self.company_data['default_journal_sale'].id,
'default_journal_id': self.customer_invoice_journal.id,
}
downpayment = self.env['sale.advance.payment.inv'].with_context(context).create({
# Create downpayment invoice
downpayment_wizard = self.env['sale.advance.payment.inv'].with_context(context).sudo().create({
'advance_payment_method': 'fixed',
'fixed_amount': 100,
'deposit_taxes_id': [Command.set(self.tax_15.ids)],
})._create_invoices(sale_order)
'fixed_amount': 115,
})
downpayment = downpayment_wizard._create_invoices(sale_order)
downpayment.invoice_date_due = '2022-09-22'
final = self.env['sale.advance.payment.inv'].with_context(context).create({})._create_invoices(sale_order)
# Create final invoice
final_wizard = self.env['sale.advance.payment.inv'].with_context(context).sudo().create({})
final = final_wizard._create_invoices(sale_order)
final.invoice_line_ids.filtered('is_downpayment').name = 'Down Payment'
final.invoice_date_due = '2022-09-22'
for move, test_file in (
(downpayment, "downpayment_invoice"),
(final, "final_invoice")
):
with self.subTest(move=move, test_file=test_file):
test_generated_file(move, test_file, self.invoice_applied_xpath)
# Test invoices
additional_xpath = f'''
<xpath expr="(//*[local-name()='PaymentMeans']/*[local-name()='InstructionID'])" position="after">
<cbc:InstructionNote xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">{sale_order.name}</cbc:InstructionNote>
</xpath>
'''
for move, test_file in [
(downpayment, "downpayment_invoice"),
(final, "final_invoice")
]:
with self.subTest(move=move, test_file=test_file):
self._test_document_generation(
test_file_path=f'l10n_sa_edi/tests/test_files/{test_file}.xml',
expected_xpath=self.invoice_applied_xpath,
additional_xpath=additional_xpath,
freeze_time_at=freeze,
move=move,
)
for move, test_file in (
(downpayment, "downpayment_credit_note"),
(final, "final_credit_note")
):
with self.subTest(move=move, test_file=test_file):
wiz_context = {
'active_model': 'account.move',
'active_ids': [move.id],
'default_journal_id': move.journal_id.id,
}
refund_invoice_wiz = self.env['account.move.reversal'].with_context(wiz_context).create({
'reason': 'please reverse :c',
'refund_method': 'refund',
'date': '2022-09-05',
})
refund_invoice = self.env['account.move'].browse(refund_invoice_wiz.reverse_moves()['res_id'])
test_generated_file(refund_invoice, test_file, self.credit_note_applied_xpath)
# Test credit notes
for move, test_file in [
(downpayment, "downpayment_credit_note"),
(final, "final_credit_note")
]:
with self.subTest(move=move, test_file=test_file):
# Create refund
wiz_context = {
'active_model': 'account.move',
'active_ids': [move.id],
'default_journal_id': move.journal_id.id,
}
refund_wizard = self.env['account.move.reversal'].with_context(wiz_context).create({
'l10n_sa_reason': 'BR-KSA-17-reason-5',
'date': '2022-09-05',
})
refund_invoice = self.env['account.move'].browse(refund_wizard.reverse_moves()['res_id'])
refund_invoice.invoice_date_due = '2022-09-22'
self._test_document_generation(
test_file_path=f'l10n_sa_edi/tests/test_files/{test_file}.xml',
expected_xpath=self.credit_note_applied_xpath,
freeze_time_at=freeze,
move=refund_invoice,
)
def testCreditNoteStandard(self):
def testInvoiceWithRetention(self):
"""Test standard invoice generation."""
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>
'''
retention_tax = self.env['account.tax'].create({
'l10n_sa_is_retention': True,
'name': 'Retention Tax',
'amount_type': 'percent',
'amount': -10.0,
})
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)
move_data = {
'name': 'INV/2022/00014',
'invoice_date': '2022-09-05',
'invoice_date_due': '2022-09-22',
'partner_id': self.partner_sa,
'invoice_line_ids': [{
'product_id': self.product_a.id,
'price_unit': self.product_a.standard_price,
'tax_ids': self.tax_15.ids + retention_tax.ids,
}]
}
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)
@freeze_time("2024-02-14 21:30:00", tz_offset=0)
def test_invoice_standard_with_accepted_time(self):
move = self._create_invoice(
name='INV/2024/00014',
date='2024-02-15',
date_due='2024-02-15',
partner_id=self.partner_us,
product_id=self.product_a,
price=320.0,
user=self.user_saudi,
self._test_document_generation(
document_type='invoice',
test_file_path='l10n_sa_edi/tests/compliance/standard/invoice.xml',
expected_xpath=self.invoice_applied_xpath,
move_data=move_data,
freeze_time_at=datetime(2022, 9, 5, 8, 20, 2, tzinfo=timezone('Etc/GMT-3'))
)
errors = self.edi_format.with_user(self.user_saudi.id)._check_move_configuration(move)
msg = '- Please, make sure the invoice date is set to either the same as or before Today.'
self.assertFalse(msg in errors)
@freeze_time("2022-09-21 15:30:00", tz_offset=0)
def test_invoice_standard_with_future_time(self):
def testCompanyOnSimplifiedInvoiceQR(self):
move_data = {
'name': 'INV/2025/00012',
'invoice_date': '2025-07-05',
'invoice_date_due': '2025-07-12',
'company_id': self.sa_branch,
'partner_id': self.partner_sa_simplified,
'invoice_line_ids': [{
'product_id': self.product_a.id,
'price_unit': self.product_a.standard_price,
'tax_ids': self.tax_15.ids,
}],
}
move = self._create_invoice(
name='INV/2024/00014',
date='2024-02-20',
date_due='2024-02-28',
partner_id=self.partner_us,
product_id=self.product_a,
price=320.0,
user=self.user_saudi,
# Fetch company name from xml
invoice = self._create_test_invoice(**move_data)
invoice.action_post()
xml_content = self.env['account.edi.format']._l10n_sa_generate_zatca_template(invoice)
xml_root = etree.fromstring(xml_content)
xml_company_name = xml_root.xpath(
"//cac:AccountingSupplierParty/cac:Party/cac:PartyName/cbc:Name",
namespaces=self.env['account.edi.xml.ubl_21.zatca']._l10n_sa_get_namespaces()
)[0].text.strip()
# Fetch company name from QR code
# Format: Tag (1 Byte) - Length (1 Byte) - Value
invoice._l10n_sa_generate_unsigned_data()
decoded_qr = base64.b64decode(invoice.l10n_sa_qr_code_str)
length = decoded_qr[1]
qr_company_name = decoded_qr[2:2 + length].decode()
self.assertEqual(xml_company_name, qr_company_name, "Seller name on the xml does not match the seller name on the QR code")
def test_company_missing_country_on_standard_invoice(self):
"""Test standard invoice generation when the company does not have a country set."""
# setup new company to prevent errors in other tests
vals = self._get_company_vals({"name": "SA Company (Minus Country)"})
new_company = self._create_company(**vals)
new_company_customer_invoice_journal = self.env['account.journal'].search([
('company_id', '=', new_company.id),
('type', '=', 'sale'),
], limit=1)
new_company_customer_invoice_journal._l10n_sa_load_edi_demo_data()
new_company.country_id = False
# missing tax should always cause a user error, even if the country is blank
move_data = {
'name': 'INV/2022/00014',
'invoice_date': '2022-09-05',
'invoice_date_due': '2022-09-22',
'company_id': new_company,
'partner_id': self.partner_sa,
'invoice_line_ids': [{
'product_id': self.product_a.id,
'price_unit': self.product_a.standard_price,
'tax_ids': False,
}],
}
invoice = self._create_test_invoice(**move_data)
with self.assertRaises(UserError):
invoice.action_post()
def test_zatca_xml_price_amount_precision(self):
"""
Test that PriceAmount has 10 decimal precision to satisfy ZATCA validation BR-KSA-EN16931-11
"""
self.tax_15.write({
'price_include_override': 'tax_included',
})
move_data = {
'name': 'INV/2025/00013',
'invoice_date': '2025-01-15',
'invoice_date_due': '2025-01-15',
'partner_id': self.partner_sa,
'invoice_line_ids': [{
'product_id': self.product_a.id,
'price_unit': 200.0,
'quantity': 7,
'tax_ids': self.tax_15.ids,
}]
}
invoice = self._create_test_invoice(**move_data)
invoice.action_post()
# Generate XML
xml_content = self.env['account.edi.format']._l10n_sa_generate_zatca_template(invoice)
xml_root = etree.fromstring(xml_content)
# Get PriceAmount from XML
price_amount_nodes = xml_root.xpath(
"//cac:InvoiceLine/cac:Price/cbc:PriceAmount",
namespaces=self.env['account.edi.xml.ubl_21.zatca']._l10n_sa_get_namespaces()
)
self.assertTrue(price_amount_nodes, "PriceAmount node not found in XML")
price_amount_str = price_amount_nodes[0].text
self.assertEqual(price_amount_str, '173.9128571429')
def test_zatca_xml_line_rounding_amount_consistency(self):
"""Test that LineExtensionAmount + TaxAmount = RoundingAmount for each invoice line."""
self.tax_15.price_include_override = 'tax_included'
invoice = self._create_test_invoice(
name='INV/2022/00001',
invoice_date='2022-09-05',
invoice_date_due='2022-09-22',
partner_id=self.partner_sa,
invoice_line_ids=[
{
'product_id': self.product_a.id,
'price_unit': 18.0,
'tax_ids': self.tax_15.ids,
},
{
'product_id': self.product_b.id,
'price_unit': 14.0,
'tax_ids': self.tax_15.ids,
}
]
)
invoice.action_post()
xml_content = self.env['account.edi.format']._l10n_sa_generate_zatca_template(invoice)
xml_root = etree.fromstring(xml_content)
namespaces = self.env['account.edi.xml.ubl_21.zatca']._l10n_sa_get_namespaces()
for line in xml_root.xpath('//cac:InvoiceLine', namespaces=namespaces):
line_ext = float(line.xpath('cbc:LineExtensionAmount/text()', namespaces=namespaces)[0])
tax_amt = float(line.xpath('cac:TaxTotal/cbc:TaxAmount/text()', namespaces=namespaces)[0])
rounding_amt = float(line.xpath('cac:TaxTotal/cbc:RoundingAmount/text()', namespaces=namespaces)[0])
self.assertEqual(line_ext + tax_amt, rounding_amt,
msg=f"LineExtensionAmount ({line_ext}) + TaxAmount ({tax_amt}) != RoundingAmount ({rounding_amt})")
def test_csr_generation_compliant_company(self):
"""Test that CSR generation succeeds for a compliant company with valid field lengths."""
compliant_company = self.env['res.company'].create({
'name': 'Valid Company Name',
'vat': '300000000000003',
'street': 'Short Street Name',
'city': 'Riyadh',
'zip': '12345',
'country_id': self.saudi_arabia.id,
'state_id': self.riyadh.id,
'l10n_sa_api_mode': 'sandbox',
'currency_id': self.env.ref('base.SAR').id,
})
compliant_company.partner_id.industry_id = self.env['res.partner.industry'].create({
'name': 'Technology',
})
compliant_company.l10n_sa_private_key_id = self.env['certificate.key'].sudo()._generate_ec_private_key(
compliant_company, name='Test private key'
)
compliant_journal = self.env['account.journal'].create({
'name': 'Sales',
'code': 'SAL',
'type': 'sale',
'company_id': compliant_company.id,
})
try:
csr_string = self.env['certificate.certificate'].sudo()._l10n_sa_get_csr_str(compliant_journal)
self.assertTrue(csr_string, "a Valid CSR should not be empty")
except UserError as e:
self.fail(f"Compliant company should not raise error: {e}")
def test_csr_generation_non_compliant_company(self):
"""Test that CSR generation fails for non-compliant company with all invalid fields listed."""
long_name = "A" * 70
long_street = "B" * 70
long_city = "C" * 70
long_state_name = "D" * 70
long_industry_name = "E" * 70
long_journal_name = "F" * 70
long_state = self.env['res.country.state'].create({
'name': long_state_name,
'code': 'LST',
'country_id': self.saudi_arabia.id,
})
long_industry = self.env['res.partner.industry'].create({
'name': long_industry_name,
})
non_compliant_company = self.env['res.company'].create({
'name': long_name,
'vat': '333333333333333',
'street': long_street,
'city': long_city,
'zip': '12345',
'country_id': self.saudi_arabia.id,
'state_id': long_state.id,
'l10n_sa_api_mode': 'sandbox',
'currency_id': self.env.ref('base.SAR').id,
})
non_compliant_company.partner_id.industry_id = long_industry
non_compliant_company.l10n_sa_private_key_id = self.env['certificate.key'].sudo()._generate_ec_private_key(
non_compliant_company, name='Test private key'
)
non_compliant_journal = self.env['account.journal'].create({
'name': long_journal_name,
'code': 'NC',
'type': 'sale',
'company_id': non_compliant_company.id,
})
with self.assertRaises(UserError) as context:
self.env['certificate.certificate'].sudo()._l10n_sa_get_csr_str(non_compliant_journal)
error_message = str(context.exception)
expected_error_fields = [
"Company Name",
"Common Name",
"Street",
"Locality Name",
"State/Province Name",
"Partner Industry Name",
]
for field_name in expected_error_fields:
self.assertIn(field_name, error_message, f"Error message should contain '{field_name}'")
def test_otp_validation_without_company_street(self):
"""Test that validating OTP fails when the company street is missing."""
self.company.street = False
journal = self.env['account.journal'].search([
*self.env['account.journal']._check_company_domain(self.company),
('type', '=', 'sale'),
], limit=1)
wizard = self.env['l10n_sa_edi.otp.wizard'].create({
'journal_id': journal.id,
'l10n_sa_otp': '123456',
})
self.assertFalse(journal.l10n_sa_csr_errors)
wizard.validate()
self.assertTrue(journal.l10n_sa_csr_errors)
self.assertEqual(
str(journal.l10n_sa_csr_errors),
f'<p>Please set the following on {self.company.name}: Street</p>'
)
errors = self.edi_format.with_user(self.user_saudi.id)._check_move_configuration(move)
msg = '- Please, make sure the invoice date is set to either the same as or before Today.'
self.assertTrue(msg in errors)

View file

@ -5,13 +5,10 @@
<cbc:UUID>ea8e1ab4-6b4e-4cb2-8efc-f8e229622774</cbc:UUID>
<cbc:IssueDate>2022-09-05</cbc:IssueDate>
<cbc:IssueTime>08:20:02</cbc:IssueTime>
<cbc:InvoiceTypeCode name="0100100">381</cbc:InvoiceTypeCode>
<cbc:InvoiceTypeCode name="0100000">381</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>SAR</cbc:DocumentCurrencyCode>
<cbc:TaxCurrencyCode>SAR</cbc:TaxCurrencyCode>
<cbc:BuyerReference>Azure Interior</cbc:BuyerReference>
<cac:OrderReference>
<cbc:ID>Reversal of: INV/2022/00001, please reverse :c</cbc:ID>
</cac:OrderReference>
<cbc:BuyerReference>Saudi Aramco</cbc:BuyerReference>
<cac:BillingReference>
<cac:InvoiceDocumentReference>
<cbc:ID>INV/2022/00001</cbc:ID>
@ -43,7 +40,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -60,7 +57,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -81,7 +78,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -99,47 +96,69 @@
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="CRN">US12345677</cbc:ID>
<cbc:ID schemeID="CRN">353535353535353</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>Chichi Lboukla</cbc:Name>
<cbc:Name>Saud Ahmed</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>4557 De Silva St</cbc:StreetName>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Fremont</cbc:CityName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>California</cbc:CountrySubentity>
<cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>US</cbc:IdentificationCode>
<cbc:Name>United States</cbc:Name>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:RegistrationName>Saud Ahmed</cbc:RegistrationName>
<cbc:CompanyID>311111111111113</cbc:CompanyID>
<cac:RegistrationAddress>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</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>Chichi Lboukla</cbc:RegistrationName>
<cbc:RegistrationName>Saud Ahmed</cbc:RegistrationName>
<cbc:CompanyID>311111111111113</cbc:CompanyID>
<cac:RegistrationAddress>
<cbc:StreetName>4557 De Silva St</cbc:StreetName>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Fremont</cbc:CityName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>California</cbc:CountrySubentity>
<cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>US</cbc:IdentificationCode>
<cbc:Name>United States</cbc:Name>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
</cac:Country>
</cac:RegistrationAddress>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:ID>350</cbc:ID>
<cbc:Name>Chichi Lboukla</cbc:Name>
<cbc:Telephone>+18709310505</cbc:Telephone>
<cbc:ElectronicMail>azure.Interior24@example.com</cbc:ElectronicMail>
<cbc:Name>Saud Ahmed</cbc:Name>
<cbc:Telephone>+966556666666</cbc:Telephone>
<cbc:ElectronicMail>saudi.aramco@example.com</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingCustomerParty>
@ -188,7 +207,7 @@
</cac:TaxTotal>
<cac:Item>
<cbc:Description>Down Payment</cbc:Description>
<cbc:Name>Down payment</cbc:Name>
<cbc:Name>Down Payment</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>15.0</cbc:Percent>

View file

@ -5,13 +5,10 @@
<cbc:UUID>7a06f916-5f83-4519-9355-89d778d246bd</cbc:UUID>
<cbc:IssueDate>2022-09-05</cbc:IssueDate>
<cbc:IssueTime>08:20:02</cbc:IssueTime>
<cbc:InvoiceTypeCode name="0100100">386</cbc:InvoiceTypeCode>
<cbc:InvoiceTypeCode name="0100000">386</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>SAR</cbc:DocumentCurrencyCode>
<cbc:TaxCurrencyCode>SAR</cbc:TaxCurrencyCode>
<cbc:BuyerReference>Azure Interior</cbc:BuyerReference>
<cac:OrderReference>
<cbc:ID>INV/2022/00001</cbc:ID>
</cac:OrderReference>
<cbc:BuyerReference>Saudi Aramco</cbc:BuyerReference>
<cac:AdditionalDocumentReference>
<cbc:ID>PIH</cbc:ID>
<cac:Attachment>
@ -38,7 +35,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -55,7 +52,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -76,7 +73,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -94,47 +91,69 @@
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="CRN">US12345677</cbc:ID>
<cbc:ID schemeID="CRN">353535353535353</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>Chichi Lboukla</cbc:Name>
<cbc:Name>Saud Ahmed</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>4557 De Silva St</cbc:StreetName>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Fremont</cbc:CityName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>California</cbc:CountrySubentity>
<cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>US</cbc:IdentificationCode>
<cbc:Name>United States</cbc:Name>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:RegistrationName>Saud Ahmed</cbc:RegistrationName>
<cbc:CompanyID>311111111111113</cbc:CompanyID>
<cac:RegistrationAddress>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</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>Chichi Lboukla</cbc:RegistrationName>
<cbc:RegistrationName>Saud Ahmed</cbc:RegistrationName>
<cbc:CompanyID>311111111111113</cbc:CompanyID>
<cac:RegistrationAddress>
<cbc:StreetName>4557 De Silva St</cbc:StreetName>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Fremont</cbc:CityName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>California</cbc:CountrySubentity>
<cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>US</cbc:IdentificationCode>
<cbc:Name>United States</cbc:Name>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
</cac:Country>
</cac:RegistrationAddress>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:ID>411</cbc:ID>
<cbc:Name>Chichi Lboukla</cbc:Name>
<cbc:Telephone>+18709310505</cbc:Telephone>
<cbc:ElectronicMail>azure.Interior24@example.com</cbc:ElectronicMail>
<cbc:Name>Saud Ahmed</cbc:Name>
<cbc:Telephone>+966556666666</cbc:Telephone>
<cbc:ElectronicMail>saudi.aramco@example.com</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingCustomerParty>
@ -183,7 +202,7 @@
</cac:TaxTotal>
<cac:Item>
<cbc:Description>Down Payment</cbc:Description>
<cbc:Name>Down payment</cbc:Name>
<cbc:Name>Down Payment</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>15.0</cbc:Percent>

View file

@ -5,13 +5,10 @@
<cbc:UUID>e2ab7427-4f07-4f3b-b874-9ca03da4880a</cbc:UUID>
<cbc:IssueDate>2022-09-05</cbc:IssueDate>
<cbc:IssueTime>08:20:02</cbc:IssueTime>
<cbc:InvoiceTypeCode name="0100100">381</cbc:InvoiceTypeCode>
<cbc:InvoiceTypeCode name="0100000">381</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>SAR</cbc:DocumentCurrencyCode>
<cbc:TaxCurrencyCode>SAR</cbc:TaxCurrencyCode>
<cbc:BuyerReference>Azure Interior</cbc:BuyerReference>
<cac:OrderReference>
<cbc:ID>Reversal of: INV/2022/00002, please reverse :c</cbc:ID>
</cac:OrderReference>
<cbc:BuyerReference>Saudi Aramco</cbc:BuyerReference>
<cac:BillingReference>
<cac:InvoiceDocumentReference>
<cbc:ID>RINV/2022/00002</cbc:ID>
@ -43,7 +40,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -60,7 +57,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -81,7 +78,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -99,47 +96,69 @@
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="CRN">US12345677</cbc:ID>
<cbc:ID schemeID="CRN">353535353535353</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>Chichi Lboukla</cbc:Name>
<cbc:Name>Saud Ahmed</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>4557 De Silva St</cbc:StreetName>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Fremont</cbc:CityName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>California</cbc:CountrySubentity>
<cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>US</cbc:IdentificationCode>
<cbc:Name>United States</cbc:Name>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:RegistrationName>Saud Ahmed</cbc:RegistrationName>
<cbc:CompanyID>311111111111113</cbc:CompanyID>
<cac:RegistrationAddress>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</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>Chichi Lboukla</cbc:RegistrationName>
<cbc:RegistrationName>Saud Ahmed</cbc:RegistrationName>
<cbc:CompanyID>311111111111113</cbc:CompanyID>
<cac:RegistrationAddress>
<cbc:StreetName>4557 De Silva St</cbc:StreetName>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Fremont</cbc:CityName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>California</cbc:CountrySubentity>
<cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>US</cbc:IdentificationCode>
<cbc:Name>United States</cbc:Name>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
</cac:Country>
</cac:RegistrationAddress>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:ID>370</cbc:ID>
<cbc:Name>Chichi Lboukla</cbc:Name>
<cbc:Telephone>+18709310505</cbc:Telephone>
<cbc:ElectronicMail>azure.Interior24@example.com</cbc:ElectronicMail>
<cbc:Name>Saud Ahmed</cbc:Name>
<cbc:Telephone>+966556666666</cbc:Telephone>
<cbc:ElectronicMail>saudi.aramco@example.com</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingCustomerParty>
@ -231,8 +250,8 @@
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:Item>
<cbc:Description>Down Payment: 09 2022 (Draft)</cbc:Description>
<cbc:Name>Down payment</cbc:Name>
<cbc:Description>Down Payment</cbc:Description>
<cbc:Name>Down Payment</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>15.0</cbc:Percent>

View file

@ -5,13 +5,10 @@
<cbc:UUID>f60b0627-777e-4374-b8a3-ea071d9220cc</cbc:UUID>
<cbc:IssueDate>2022-09-05</cbc:IssueDate>
<cbc:IssueTime>08:20:02</cbc:IssueTime>
<cbc:InvoiceTypeCode name="0100100">388</cbc:InvoiceTypeCode>
<cbc:InvoiceTypeCode name="0100000">388</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>SAR</cbc:DocumentCurrencyCode>
<cbc:TaxCurrencyCode>SAR</cbc:TaxCurrencyCode>
<cbc:BuyerReference>Azure Interior</cbc:BuyerReference>
<cac:OrderReference>
<cbc:ID>INV/2022/00002</cbc:ID>
</cac:OrderReference>
<cbc:BuyerReference>Saudi Aramco</cbc:BuyerReference>
<cac:AdditionalDocumentReference>
<cbc:ID>PIH</cbc:ID>
<cac:Attachment>
@ -38,7 +35,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -55,7 +52,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -76,7 +73,7 @@
<cbc:CityName>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</cbc:CityName>
<cbc:PostalZone>42317</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
@ -94,47 +91,69 @@
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="CRN">US12345677</cbc:ID>
<cbc:ID schemeID="CRN">353535353535353</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>Chichi Lboukla</cbc:Name>
<cbc:Name>Saud Ahmed</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>4557 De Silva St</cbc:StreetName>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Fremont</cbc:CityName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>California</cbc:CountrySubentity>
<cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>US</cbc:IdentificationCode>
<cbc:Name>United States</cbc:Name>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:RegistrationName>Saud Ahmed</cbc:RegistrationName>
<cbc:CompanyID>311111111111113</cbc:CompanyID>
<cac:RegistrationAddress>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</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>Chichi Lboukla</cbc:RegistrationName>
<cbc:RegistrationName>Saud Ahmed</cbc:RegistrationName>
<cbc:CompanyID>311111111111113</cbc:CompanyID>
<cac:RegistrationAddress>
<cbc:StreetName>4557 De Silva St</cbc:StreetName>
<cbc:StreetName>4557 King Salman St</cbc:StreetName>
<cbc:BuildingNumber>12300</cbc:BuildingNumber>
<cbc:PlotIdentification>2323</cbc:PlotIdentification>
<cbc:CitySubdivisionName>Neighbor!</cbc:CitySubdivisionName>
<cbc:CityName>Fremont</cbc:CityName>
<cbc:CityName>Riyadh</cbc:CityName>
<cbc:PostalZone>94538</cbc:PostalZone>
<cbc:CountrySubentity>California</cbc:CountrySubentity>
<cbc:CountrySubentityCode>CA</cbc:CountrySubentityCode>
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
<cbc:CountrySubentityCode>RUH</cbc:CountrySubentityCode>
<cac:Country>
<cbc:IdentificationCode>US</cbc:IdentificationCode>
<cbc:Name>United States</cbc:Name>
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
<cbc:Name>Saudi Arabia</cbc:Name>
</cac:Country>
</cac:RegistrationAddress>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:ID>320</cbc:ID>
<cbc:Name>Chichi Lboukla</cbc:Name>
<cbc:Telephone>+18709310505</cbc:Telephone>
<cbc:ElectronicMail>azure.Interior24@example.com</cbc:ElectronicMail>
<cbc:Name>Saud Ahmed</cbc:Name>
<cbc:Telephone>+966556666666</cbc:Telephone>
<cbc:ElectronicMail>saudi.aramco@example.com</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingCustomerParty>
@ -226,8 +245,8 @@
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:Item>
<cbc:Description>Down Payment: 09 2022 (Draft)</cbc:Description>
<cbc:Name>Down payment</cbc:Name>
<cbc:Description>Down Payment</cbc:Description>
<cbc:Name>Down Payment</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>15.0</cbc:Percent>

View file

@ -0,0 +1,209 @@
<Invoice xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-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>___ignore___</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">___ignore___</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>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</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>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</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>&#1575;&#1604;&#1605;&#1583;&#1610;&#1606;&#1577; &#1575;&#1604;&#1605;&#1606;&#1608;&#1585;&#1577;</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>___ignore___</cbc:ID>
<cbc:Name>SA Company Test</cbc:Name>
<cbc:Telephone>+966512345678</cbc:Telephone>
<cbc:ElectronicMail>info@company.saexample.com</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<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>___ignore___</cbc:ID>
<cbc:Name>Chichi Lboukla</cbc:Name>
<cbc:Telephone>+18709310505</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:AllowanceCharge>
<cbc:ChargeIndicator>false</cbc:ChargeIndicator>
<cbc:AllowanceChargeReasonCode>95</cbc:AllowanceChargeReasonCode>
<cbc:AllowanceChargeReason>Discount</cbc:AllowanceChargeReason>
<cbc:Amount currencyID="SAR">100.00</cbc:Amount>
<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:AllowanceCharge>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="SAR">33.00</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="SAR">220.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="SAR">33.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">33.00</cbc:TaxAmount>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="SAR">320.00</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="SAR">220.00</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="SAR">253.00</cbc:TaxInclusiveAmount>
<cbc:AllowanceTotalAmount currencyID="SAR">100.00</cbc:AllowanceTotalAmount>
<cbc:PrepaidAmount currencyID="SAR">0.00</cbc:PrepaidAmount>
<cbc:PayableAmount currencyID="SAR">253.00</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</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: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.0</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>

View file

@ -0,0 +1,32 @@
from odoo import Command
from odoo.tests import tagged
from odoo.addons.l10n_sa_edi.tests.common import TestSaEdiCommon
@tagged('post_install_l10n', 'post_install', '-at_install')
class TestL10nSaInvoice(TestSaEdiCommon):
def test_invoice_section_lines_rendering(self):
invoice = self.env['account.move'].create([{
'move_type': 'out_invoice',
'partner_id': self.partner_sa_simplified.id,
'invoice_line_ids': [
Command.create({
'product_id': self.product_a.id,
'tax_ids': self.tax_15.ids,
'price_unit': 100.0,
}),
Command.create({
'display_type': 'line_section',
'name': 'section line',
}),
Command.create({
'product_id': self.product_b.id,
'tax_ids': self.tax_15.ids,
'price_unit': 200.0,
})
]
}])
invoice.action_post()
html = self.env['ir.actions.report']._render_qweb_html('account.report_invoice_with_payments', invoice.ids)[0]
self.assertTrue(html)

View file

@ -12,75 +12,44 @@
<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'), ('type', '!=', 'sale')]}">
<group>
<group>
<field name="l10n_sa_serial_number" readonly="1" groups="base.group_no_one"/>
</group>
</group>
<p groups="base.group_system">
<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" groups="base.group_system"
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" groups="base.group_system"
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" groups="base.group_system"
attrs="{'invisible':['|', ('l10n_sa_csr_errors', '!=', False), ('l10n_sa_compliance_csid_json', '!=', False)]}">
<page name="zatca_einvoicing" string="ZATCA" invisible="country_code != 'SA' or type != 'sale'">
<div class="alert alert-info d-flex justify-content-between align-items-center" role="alert"
invisible="l10n_sa_csr_errors or l10n_sa_compliance_csid_json" groups="base.group_system">
<p class="mb-0">
Onboard the Journal by completing each step
Onboard this Journal
</p>
<button name="%(l10n_sa_edi_otp_wizard_act_window)d" type="action" icon="fa-key"
class="btn-info ">
Onboard Journal
Onboard
</button>
</div>
<div class="alert alert-danger d-flex flex-column align-items-end" role="alert" groups="base.group_system"
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>
<div class="alert alert-danger d-flex justify-content-between align-items-center" role="alert"
groups="base.group_system"
invisible="not l10n_sa_csr_errors or l10n_sa_compliance_csid_json or l10n_sa_production_csid_json">
<div>
<h4 role="alert" class="alert-heading">Error: Journal is not onboarded</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
Onboard
</button>
</div>
<div class="alert alert-info d-flex justify-content-between align-items-center" role="alert" groups="base.group_system"
attrs="{'invisible':['|', ('l10n_sa_compliance_checks_passed', '=', False), ('l10n_sa_production_csid_json', '=', False)]}">
<div class="alert alert-info d-flex justify-content-between align-items-center" role="alert"
groups="base.group_system"
invisible="not l10n_sa_compliance_checks_passed or not l10n_sa_production_csid_json">
<p class="mb-0">
The Production certificate is valid until
Success: Journal is onboarded and 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
Renew
</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?">
confirm="Do you really want to re-onboard this Journal with ZATCA?" confirm-title="Re-onboarding Confirmation" confirm-label="Yes">
Re-Onboard
</button>
</div>
@ -90,5 +59,19 @@
</field>
</record>
<record id="view_account_form_inherit" model="ir.ui.view">
<field name="name">account.move.form.inherit</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form" />
<field name="arch" type="xml">
<xpath expr="//button[@name='action_retry_edi_documents_error']" position="attributes">
<attribute name="invisible">l10n_sa_edi_chain_head_id</attribute>
</xpath>
<xpath expr="//button[@name='action_retry_edi_documents_error']" position="after">
<!-- when invoice is stuck due to chain head, retry action does not make sense (there are no issues with the current invoice), and instead, we redirect them to the affected invoice -->
<button name="action_show_chain_head" type="object" class="oe_link py-0 text-danger" string="Blocking Invoice" invisible="not l10n_sa_edi_chain_head_id"/>
</xpath>
</field>
</record>
</data>
</odoo>

View file

@ -7,8 +7,8 @@
<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)]}"/>
<field name="l10n_sa_is_retention" invisible="type_tax_use != 'sale' or country_code != 'SA' or amount &gt;= 0"/>
<field name="l10n_sa_exemption_reason_code" invisible="type_tax_use != 'sale' or country_code != 'SA' or amount != 0"/>
</xpath>
</field>
</record>

View file

@ -1,69 +1,18 @@
<?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">
<table t-if="o.company_id.country_id.code == 'SA' and o.currency_id != o.company_id.currency_id"
id="sar_amounts" style="page-break-inside: avoid;" class="clearfix mx-auto mt-3 table-sm table-borderless">
<t t-set="curr_date" t-value="o.invoice_date or datetime.datetime.today()"></t>
<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, curr_date)"/>
<tr>
<td class="w-25 text-start" dir="rtl"><strong>سعر الصرف</strong></td>
<td class="w-25 text-start" dir="rtl"><strong>الإجمالي الفرعي بالريال السعودي</strong></td>
<td class="w-25 text-start" dir="rtl"><strong>مبلغ ضريبة القيمة المضافة بالريال السعودي</strong></td>
<td class="w-25 text-start" dir="rtl"><strong>الإجمالي بالريال السعودي</strong></td>
</tr>
<tr>
<td class="w-25"><span class="fw-bold">Exchange Rate</span></td>
<td class="w-25"><span class="fw-bold">Subtotal</span></td>
<td class="w-25"><span class="fw-bold">VAT Amount</span></td>
<td class="w-25"><span class="fw-bold">Total</span></td>
</tr>
<tr>
<td><p class="m-0" t-esc="sar_rate" t-options='{"widget": "float", "precision": 5}'/></td>
<td><p class="m-0" t-esc="o.amount_untaxed_signed"
t-options='{"widget": "monetary", "display_currency": o.company_currency_id}'/></td>
<td><p class="m-0"
t-esc="o.currency_id._convert(o.amount_tax, o.company_id.currency_id, o.company_id, curr_date)"
t-options='{"widget": "monetary", "display_currency": o.company_currency_id}'/></td>
<td><p class="m-0" t-esc="o.amount_total_signed"
t-options='{"widget": "monetary", "display_currency": o.company_currency_id}'/></td>
</tr>
</table>
</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=&quot;o.move_type == &apos;out_invoice&apos; and o.state == &apos;posted&apos;&quot;]" 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
<template id="l10n_sa_edi_report_invoice_document" inherit_id="l10n_sa.l10n_sa_report_invoice_document">
<!-- Address format -->
<xpath expr="//div[hasclass('invoice_main')]" position="before">
<t t-set="address">
<t t-out="address"/>
<t t-if="o.partner_id.l10n_sa_edi_additional_identification_scheme and o.partner_id.l10n_sa_edi_additional_identification_number">
<span t-field="o.partner_id.l10n_sa_edi_additional_identification_scheme"/>:
<span t-field="o.partner_id.l10n_sa_edi_additional_identification_number"/>
</t>
<t t-else="">
Tax Invoice
</t>
</span>
</t>
</xpath>
<xpath expr="//div[hasclass('col-4')][3]//span[@t-if=&quot;o.move_type == &apos;out_invoice&apos; and o.state == &apos;posted&apos;&quot;]" 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>
<!-- End Address format -->
</template>
</data>
</odoo>

View file

@ -8,15 +8,19 @@
<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')]}"
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')]}"
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')]}"/>
<field name="l10n_sa_edi_additional_identification_scheme" invisible="country_code != 'SA'"/>
<field name="l10n_sa_edi_additional_identification_number" string="Identification Number" invisible="country_code != 'SA' or l10n_sa_edi_additional_identification_scheme == 'TIN'"/>
</xpath>
<xpath expr="//field[@name='street2']" position="replace">
<field name="street2" placeholder="District" class="o_address_street" invisible="country_code != 'SA'"/>
<field name="street2" placeholder="Street 2..." class="o_address_street" invisible="country_code == 'SA'"/>
</xpath>
</field>
</record>

View file

@ -6,36 +6,36 @@
<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">
<xpath expr="//app[@name='account']/block" 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 &amp; Penalties</strong>.
<block title="Saudi Arabia Electronic Invoicing" name="zatca_einvoicing_setting_container" invisible="country_code != 'SA'">
<setting class="col-lg-12" string="ZATCA API Modes" company_dependent="1">
<div class="text-muted">
<div>1. There are three modes available:</div>
<ul class="mb8">
<li><span class="o_form_label">Sandbox</span> - Common pre-configured testing environment</li>
<li><span class="o_form_label">Simulation</span> - Unique testing environment</li>
<li><span class="o_form_label">Production</span> - Live environment</li>
</ul>
<div>2. After selecting the mode, please go to <span class="o_form_label">Journals &gt; Sales Type &gt; ZATCA Tab &gt; Onboard.</span></div>
<div>3. Once the Journal is onboarded, you can begin using the selected mode.</div>
</div>
<div class="content-group col-lg-6">
<div class="row mt16">
<label for="l10n_sa_api_mode" string="API Mode" class="col-3 o_light_label"/>
<field name="l10n_sa_api_mode" help="Set whether the system should use the Production API"/>
</div>
</div>
</div>
</div>
<div class="alert alert-warning mt8 col-lg-6" role="alert" invisible="l10n_sa_api_mode != 'prod'">
<h4 class="alert-heading" role="alert">
<i class="fa fa-warning me-2" /> Warning
</h4>
Once the mode is set to <strong>Production</strong> and an Invoice has been submitted to ZATCA,
then the API mode cannot be changed. Any invoice submitted in the Production mode will be officially
recorded with ZATCA and considered in the calculation of the VAT Liability.
</div>
</setting>
</block>
</xpath>
</field>
</record>

View file

@ -7,10 +7,10 @@
<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>
<xpath expr="//div[hasclass('o_address_format')]" position="after">
<field name="l10n_sa_edi_additional_identification_scheme" invisible="'SA' not in fiscal_country_codes or country_code != 'SA'"/>
<field name="l10n_sa_edi_additional_identification_number" string="Identification Number" invisible="'SA' not in fiscal_country_codes and country_code != 'SA' or l10n_sa_edi_additional_identification_scheme == 'TIN'"/>
</xpath>
</field>
</record>

View file

@ -1,3 +1,2 @@
from . import account_move_reversal
from . import account_debit_note
from . import base_document_layout
from . import l10n_sa_edi_otp_wizard

View file

@ -1,15 +0,0 @@
# -*- 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()

View file

@ -1,15 +0,0 @@
# -*- 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()

View file

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_account_move_reversal_inherit_l10n_sa_edi" model="ir.ui.view">
<field name="name">account.move.reversal.form.inherit.l10n_sa_edi</field>
<field name="inherit_id" ref="account.view_account_move_reversal"/>
<field name="model">account.move.reversal</field>
<field name="arch" type="xml">
<field name="reason" position="replace">
<field name="country_code" invisible="1"/>
<field name="reason" string="Reason" attrs="{'invisible': [('move_type', '=', 'entry'), ('country_code', '!=', 'SA')], 'required': [('country_code', '=', 'SA')]}"/>
</field>
</field>
</record>
</data>
</odoo>

View file

@ -0,0 +1,22 @@
from markupsafe import Markup
from odoo import api, models
class BaseDocumentLayout(models.TransientModel):
_inherit = 'base.document.layout'
@api.model
def _default_company_details(self):
if self.env.company.country_code != 'SA':
return super()._default_company_details()
additional_company_details = ""
if self.env.company.vat:
additional_company_details += Markup('VAT Number: %s <br/>') % self.env.company.vat
if (code := self.env.company.l10n_sa_edi_additional_identification_scheme) and self.env.company.l10n_sa_edi_additional_identification_number:
code_value = dict(self._fields['l10n_sa_edi_additional_identification_scheme']._description_selection(self.env))[code]
additional_company_details += f"{code_value}: {self.env.company.l10n_sa_edi_additional_identification_number}"
return super()._default_company_details() + Markup('<br/><br/>%s') % additional_company_details

View file

@ -2,7 +2,7 @@ from odoo import fields, models, _, api
from odoo.exceptions import UserError
class RequestZATCAOtp(models.TransientModel):
class L10n_Sa_EdiOtpWizard(models.TransientModel):
_name = 'l10n_sa_edi.otp.wizard'
_description = 'Request ZATCA OTP'
@ -22,7 +22,7 @@ class RequestZATCAOtp(models.TransientModel):
def validate(self):
if not self.l10n_sa_otp:
raise UserError(_("You need to provide an OTP to be able to request a CCSID"))
raise UserError(_("Please provide an OTP to complete the onboarding process"))
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)

View file

@ -6,22 +6,22 @@
<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.
Please input the OTP generated from the Fatoora Portal.
<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"/>
<button string="Confirm" type="object" name="validate" class="btn btn-primary" data-hotkey="q"/>
<button string="Cancel" special="cancel" data-hotkey="x" 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="name">Enter the OTP</field>
<field name="res_model">l10n_sa_edi.otp.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>

View file

@ -2,16 +2,19 @@
name = "odoo-bringout-oca-ocb-l10n_sa_edi"
version = "16.0.0"
description = "Saudi Arabia - E-invoicing -
E-Invoicing, Universal Business Language
"
"
authors = [
{ name = "Ernad Husremovic", email = "hernad@bring.out.ba" }
]
dependencies = [
"odoo-bringout-oca-ocb-account_edi_ubl_cii>=16.0.0",
"odoo-bringout-oca-ocb-account_debit_note>=16.0.0",
"odoo-bringout-oca-ocb-l10n_sa>=16.0.0",
"odoo-bringout-oca-ocb-base_vat>=16.0.0",
"odoo-bringout-oca-ocb-account_edi>=19.0.0",
"odoo-bringout-oca-ocb-account_edi_ubl_cii>=19.0.0",
"odoo-bringout-oca-ocb-l10n_sa>=19.0.0",
"odoo-bringout-oca-ocb-base_vat>=19.0.0",
"TODO_MAP-certificate>=19.0.0",
"requests>=2.25.1"
]
readme = "README.md"
@ -21,7 +24,7 @@ classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Office/Business",
]