mirror of
https://github.com/bringout/oca-ocb-l10n_me-africa.git
synced 2026-04-28 03:42:04 +02:00
19.0 vanilla
This commit is contained in:
parent
c5006a6999
commit
80293571e7
420 changed files with 21812 additions and 44297 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,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>
|
||||
|
|
|
|||
|
|
@ -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
File diff suppressed because it is too large
Load diff
|
|
@ -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'))
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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}""",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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."))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@
|
|||
|
||||
from . import common
|
||||
from . import test_edi_zatca
|
||||
from . import test_invoice
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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>المدينة المنورة</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>المدينة المنورة</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>المدينة المنورة</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>
|
||||
|
|
|
|||
|
|
@ -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>المدينة المنورة</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>المدينة المنورة</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>المدينة المنورة</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>
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
<cbc:CityName>المدينة المنورة</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>المدينة المنورة</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>المدينة المنورة</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>
|
||||
|
|
|
|||
|
|
@ -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>المدينة المنورة</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>المدينة المنورة</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>المدينة المنورة</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>
|
||||
|
|
|
|||
|
|
@ -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>المدينة المنورة</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>المدينة المنورة</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>المدينة المنورة</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>
|
||||
|
|
|
|||
|
|
@ -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>المدينة المنورة</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>المدينة المنورة</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>المدينة المنورة</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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>المدينة المنورة</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>المدينة المنورة</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>المدينة المنورة</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>
|
||||
|
|
|
|||
|
|
@ -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>المدينة المنورة</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>المدينة المنورة</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>المدينة المنورة</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>
|
||||
|
|
|
|||
|
|
@ -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>المدينة المنورة</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>المدينة المنورة</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>المدينة المنورة</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>
|
||||
|
|
|
|||
|
|
@ -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>المدينة المنورة</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>المدينة المنورة</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>المدينة المنورة</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>
|
||||
|
|
|
|||
|
|
@ -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>المدينة المنورة</cbc:CityName>
|
||||
<cbc:PostalZone>42317</cbc:PostalZone>
|
||||
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
|
||||
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
|
||||
<cbc:Name>Saudi Arabia</cbc:Name>
|
||||
</cac:Country>
|
||||
</cac:PostalAddress>
|
||||
<cac:PartyTaxScheme>
|
||||
<cbc:RegistrationName>SA Company Test</cbc:RegistrationName>
|
||||
<cbc:CompanyID>311111111111113</cbc:CompanyID>
|
||||
<cac:RegistrationAddress>
|
||||
<cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName>
|
||||
<cbc:BuildingNumber>1234</cbc:BuildingNumber>
|
||||
<cbc:PlotIdentification>1234</cbc:PlotIdentification>
|
||||
<cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName>
|
||||
<cbc:CityName>المدينة المنورة</cbc:CityName>
|
||||
<cbc:PostalZone>42317</cbc:PostalZone>
|
||||
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
|
||||
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
|
||||
<cbc:Name>Saudi Arabia</cbc:Name>
|
||||
</cac:Country>
|
||||
</cac:RegistrationAddress>
|
||||
<cac:TaxScheme>
|
||||
<cbc:ID>VAT</cbc:ID>
|
||||
</cac:TaxScheme>
|
||||
</cac:PartyTaxScheme>
|
||||
<cac:PartyLegalEntity>
|
||||
<cbc:RegistrationName>SA Company Test</cbc:RegistrationName>
|
||||
<cbc:CompanyID>311111111111113</cbc:CompanyID>
|
||||
<cac:RegistrationAddress>
|
||||
<cbc:StreetName>Al Amir Mohammed Bin Abdul Aziz Street</cbc:StreetName>
|
||||
<cbc:BuildingNumber>1234</cbc:BuildingNumber>
|
||||
<cbc:PlotIdentification>1234</cbc:PlotIdentification>
|
||||
<cbc:CitySubdivisionName>Testomania</cbc:CitySubdivisionName>
|
||||
<cbc:CityName>المدينة المنورة</cbc:CityName>
|
||||
<cbc:PostalZone>42317</cbc:PostalZone>
|
||||
<cbc:CountrySubentity>Riyadh</cbc:CountrySubentity>
|
||||
<cbc:CountrySubentityCode>RYA</cbc:CountrySubentityCode>
|
||||
<cac:Country>
|
||||
<cbc:IdentificationCode>SA</cbc:IdentificationCode>
|
||||
<cbc:Name>Saudi Arabia</cbc:Name>
|
||||
</cac:Country>
|
||||
</cac:RegistrationAddress>
|
||||
</cac:PartyLegalEntity>
|
||||
<cac:Contact>
|
||||
<cbc:ID>___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>
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 >= 0"/>
|
||||
<field name="l10n_sa_exemption_reason_code" invisible="type_tax_use != 'sale' or country_code != 'SA' or amount != 0"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
|
|
|||
|
|
@ -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="o.move_type == 'out_invoice' and o.state == 'posted'"]" position="replace">
|
||||
<span t-if="o.move_type == 'out_invoice' and o.state == 'posted'">
|
||||
<t t-if="o._l10n_sa_is_simplified()">
|
||||
Simplified Tax Invoice
|
||||
<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="o.move_type == 'out_invoice' and o.state == 'posted'"]" position="replace">
|
||||
<span t-if="o.move_type == 'out_invoice' and o.state == 'posted'">
|
||||
<t t-if="o._l10n_sa_is_simplified()">
|
||||
فاتورة ضريبية مبسطة
|
||||
</t>
|
||||
<t t-else="">
|
||||
فاتورة ضريبية
|
||||
</t>
|
||||
</span>
|
||||
</xpath>
|
||||
|
||||
<!-- End Address format -->
|
||||
</template>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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 > Sales Type > ZATCA Tab > 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue