diff --git a/addons/l10n_es_edi_tbai/__init__.py b/addons/l10n_es_edi_tbai/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3d0197513d591d37f30e60ed986dee45482afd54 --- /dev/null +++ b/addons/l10n_es_edi_tbai/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models +from . import wizards diff --git a/addons/l10n_es_edi_tbai/__manifest__.py b/addons/l10n_es_edi_tbai/__manifest__.py new file mode 100644 index 0000000000000000000000000000000000000000..ab2337cec617ce81d836e4be6be6d9838250c72e --- /dev/null +++ b/addons/l10n_es_edi_tbai/__manifest__.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +# Thanks to Landoo and the Spanish community +# Specially among others Aritz Olea, Luis Salvatierra, Josean Soroa + +{ + 'name': "Spain - TicketBAI", + 'version': '1.0', + 'category': 'Accounting/Localizations/EDI', + 'application': False, + 'description': """ + This module sends invoices and vendor bills to the "Diputaciones + Forales" of Araba/Ãlava, Bizkaia and Gipuzkoa. + + Invoices and bills get converted to XML and regularly sent to the + Basque government servers which provides them with a unique identifier. + A hash chain ensures the continuous nature of the invoice/bill + sequences. QR codes are added to emitted (sent/printed) invoices, + bills and tickets to allow anyone to check they have been declared. + + You need to configure your certificate and the tax agency. + """, + 'depends': [ + 'l10n_es_edi_sii', + ], + 'data': [ + 'data/account_edi_data.xml', + 'data/template_invoice.xml', + 'data/template_LROE_bizkaia.xml', + + 'views/account_move_view.xml', + 'views/report_invoice.xml', + 'views/res_config_settings_views.xml', + 'views/res_company_views.xml', + + 'wizards/account_move_reversal_views.xml', + ], + 'demo': [ + 'demo/demo_res_partner.xml', + 'demo/demo_company.xml', + ], + 'license': 'LGPL-3', +} diff --git a/addons/l10n_es_edi_tbai/data/account_edi_data.xml b/addons/l10n_es_edi_tbai/data/account_edi_data.xml new file mode 100644 index 0000000000000000000000000000000000000000..8d21a99f4482d5f509c5dfb7fdc8ac1d41d41198 --- /dev/null +++ b/addons/l10n_es_edi_tbai/data/account_edi_data.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + <record id="edi_es_tbai" model="account.edi.format"> + <field name="name">TicketBAI (ES)</field> + <field name="code">es_tbai</field> + </record> + </data> +</odoo> diff --git a/addons/l10n_es_edi_tbai/data/template_LROE_bizkaia.xml b/addons/l10n_es_edi_tbai/data/template_LROE_bizkaia.xml new file mode 100644 index 0000000000000000000000000000000000000000..318862b0dcdf88487c24b69475dc312f9e09a5f4 --- /dev/null +++ b/addons/l10n_es_edi_tbai/data/template_LROE_bizkaia.xml @@ -0,0 +1,38 @@ +<?xml version='1.0' encoding='utf-8'?> +<!--Bizkaia uses an extra layer to send TicketBAI invoices, called LROE + see https://www.batuz.eus/es/documentacion-tecnica --> +<odoo> + <data> + <template id="template_LROE_240_main"> + <lrpjfecsgap:LROEPJ240FacturasEmitidasConSGAltaPeticion + xmlns:lrpjfecsgap="https://www.batuz.eus/fitxategiak/batuz/LROE/esquemas/LROE_PJ_240_1_1_FacturasEmitidas_ConSG_AltaPeticion_V1_0_2.xsd" + t-if="is_emission" + t-call="l10n_es_edi_tbai.template_LROE_240_inner"/> + <lrpjfecsgap:LROEPJ240FacturasEmitidasConSGAnulacionPeticion + xmlns:lrpjfecsgap="https://www.batuz.eus/fitxategiak/batuz/LROE/esquemas/LROE_PJ_240_1_1_FacturasEmitidas_ConSG_AnulacionPeticion_V1_0_0.xsd" + t-else="" + t-call="l10n_es_edi_tbai.template_LROE_240_inner"/> + </template> + + <template id="template_LROE_240_inner"> + <Cabecera> + <Modelo>240</Modelo> + <Capitulo>1</Capitulo> + <Subcapitulo>1.1</Subcapitulo> + <Operacion t-out="'A00' if is_emission else 'AN0'"/> + <Version>1.0</Version> + <Ejercicio t-out="fiscal_year"/> + <ObligadoTributario> + <NIF t-out="sender_vat"/> + <ApellidosNombreRazonSocial t-out="sender.name"/> + </ObligadoTributario> + </Cabecera> + <FacturasEmitidas> + <FacturaEmitida t-foreach="tbai_b64_list" t-as="tbai_b64"> + <TicketBai t-if="is_emission" t-out="tbai_b64"/> + <AnulacionTicketBai t-else="" t-out="tbai_b64"/> + </FacturaEmitida> + </FacturasEmitidas> + </template> + </data> +</odoo> diff --git a/addons/l10n_es_edi_tbai/data/template_invoice.xml b/addons/l10n_es_edi_tbai/data/template_invoice.xml new file mode 100644 index 0000000000000000000000000000000000000000..0e280e9fb30f6e1a8de9d8759bfda29d9344cef9 --- /dev/null +++ b/addons/l10n_es_edi_tbai/data/template_invoice.xml @@ -0,0 +1,266 @@ +<?xml version='1.0' encoding='utf-8'?> +<odoo> + <data> + <template id="template_invoice_main_post"> + <T:TicketBai xmlns:T="urn:ticketbai:emision"> + <t t-call="l10n_es_edi_tbai.template_invoice_bundle"/> + </T:TicketBai> + </template> + + <template id="template_invoice_main_cancel"> + <T:AnulaTicketBai xmlns:T="urn:ticketbai:anulacion"> + <t t-call="l10n_es_edi_tbai.template_invoice_bundle"/> + </T:AnulaTicketBai> + </template> + + <template id="template_invoice_bundle"> + <Cabecera> + <IDVersionTBAI t-out="tbai_version"/> + </Cabecera> + <Sujetos t-if="is_emission"> + <t t-call="l10n_es_edi_tbai.template_invoice_sujetos"/> + </Sujetos> + <Factura t-if="is_emission"> + <t t-call="l10n_es_edi_tbai.template_invoice_factura"/> + </Factura> + <IDFactura t-if="not is_emission"> + <t t-call="l10n_es_edi_tbai.template_invoice_sujetos"/> + <t t-call="l10n_es_edi_tbai.template_invoice_factura"/> + </IDFactura> + <HuellaTBAI> + <EncadenamientoFacturaAnterior t-if="chain_prev_invoice"> + <t t-set="seq_and_num" t-value="chain_prev_invoice._get_l10n_es_tbai_sequence_and_number()"/> + <SerieFacturaAnterior t-out="seq_and_num[0]"/> + <NumFacturaAnterior t-out="seq_and_num[1]"/> + <t t-set="sig_and_date" t-value="chain_prev_invoice._get_l10n_es_tbai_signature_and_date()"/> + <FechaExpedicionFacturaAnterior t-out="format_date(sig_and_date[1])"/> + <SignatureValueFirmaFacturaAnterior t-out="sig_and_date[0][:100]"/> + </EncadenamientoFacturaAnterior> + <Software> + <LicenciaTBAI t-out="license_number"/> + <EntidadDesarrolladora> + <NIF t-out="license_nif"/> + </EntidadDesarrolladora> + <Nombre t-out="software_name"/> + <Version t-out="software_version"/> + </Software> + <NumSerieDispositivo>TEST-DEVICE-001</NumSerieDispositivo> + </HuellaTBAI> + </template> + + <template id="template_invoice_sujetos"> + <Emisor> + <NIF t-out="sender_vat"/> + <ApellidosNombreRazonSocial t-out="sender.name"/> + </Emisor> + <Destinatarios t-if="is_emission and recipient"> + <IDDestinatario> + <NIF t-if="recipient.get('nif')" t-out="recipient['nif']"/> + <IDOtro t-else=""> + <CodigoPais t-if="recipient.get('alt_id_country')" t-out="recipient['alt_id_country']"/> + <IDType t-out="recipient['alt_id_type']"/> + <ID t-out="recipient['alt_id_number']"/> + </IDOtro> + <t t-set="partner" t-value="recipient['partner']"/> + <ApellidosNombreRazonSocial t-out="partner.name"/> + <CodigoPostal t-out="partner.zip"/> + <Direccion t-out="recipient['partner_address']"/> + </IDDestinatario> + </Destinatarios> + <VariosDestinatarios t-if="is_emission">N</VariosDestinatarios> <!-- Odoo does not support multi-recipient invoices (TBAI does)--> + <EmitidaPorTercerosODestinatario t-if="is_emission">N</EmitidaPorTercerosODestinatario> + </template> + + <template id="template_invoice_factura"> + <t t-set="is_simplified" t-value="invoice._is_l10n_es_tbai_simplified()"/> + <CabeceraFactura> + <t t-set="seq_and_num" t-value="invoice._get_l10n_es_tbai_sequence_and_number()"/> + <SerieFactura t-out="seq_and_num[0]"/> + <NumFactura t-out="seq_and_num[1]"/> + <t t-if="is_emission"> + <FechaExpedicionFactura t-out="format_date(datetime_now)"/> + <HoraExpedicionFactura t-out="format_time(datetime_now)"/> + <FacturaSimplificada t-out="'S' if is_simplified else 'N'"/> + </t> + <FechaExpedicionFactura t-else="" t-out="format_date(invoice._get_l10n_es_tbai_signature_and_date()[1])"/> + <t t-if="is_refund"> + <FacturaEmitidaSustitucionSimplificada t-out="'S' if (is_simplified and recipient) else 'N'"/> + <FacturaRectificativa> + <Codigo t-out="credit_note_code"/> + <Tipo>I</Tipo> + <!-- NOTE: could also allow credit note Tipo 'S' (optional, tipo I already supported by SII) + <ImporteRectificacionSustitutiva> + <BaseRectificada>180.00</BaseRectificada> + <CuotaRectificada>20.21</CuotaRectificada> + </ImporteRectificacionSustitutiva> --> + </FacturaRectificativa> + <FacturasRectificadasSustituidas> + <IDFacturaRectificadaSustituida> + <!-- NOTE: could support issuing a single credit note for multiple invoices (optional) --> + <t t-set="seq_and_num" t-value="credit_note_invoice._get_l10n_es_tbai_sequence_and_number()"/> + <SerieFactura t-out="seq_and_num[0]"/> + <NumFactura t-out="seq_and_num[1]"/> + <FechaExpedicionFactura t-out="format_date(credit_note_invoice._get_l10n_es_tbai_signature_and_date()[1])"/> + </IDFacturaRectificadaSustituida> + </FacturasRectificadasSustituidas> + </t> + </CabeceraFactura> + <DatosFactura t-if="is_emission"> + <DescripcionFactura t-out="invoice.invoice_origin or 'manual'"/> + <DetallesFactura> + <IDDetalleFactura t-foreach="invoice_lines" t-as="line_values"> + <t t-set="line" t-value="line_values['line']"/> + <DescripcionDetalle t-out="line_values['description']"/> + <Cantidad t-out="format_float(line.quantity)"/> + <ImporteUnitario t-out="format_float(line_values['unit_price'])"/> + <Descuento t-out="format_float(line_values['discount'])"/> + <ImporteTotal t-out="format_float(line_values['total'])"/> + </IDDetalleFactura> + </DetallesFactura> + <ImporteTotalFactura t-out="format_float(amount_total)"/> + <!-- <RetencionSoportada/> NOTE (potentially has to be computed/decided manually) --> + <!-- <BaseImponibleACoste/> NOTE (only applicable with ClaveRegimenIvaOpTrascendencia 06, not supported yet) --> + <Claves> + <IDClave t-foreach="regime_key" t-as="key"> + <ClaveRegimenIvaOpTrascendencia t-out="key"/> + </IDClave> + </Claves> + </DatosFactura> + <TipoDesglose t-if="is_emission"> + <DesgloseFactura t-if="'DesgloseFactura' in invoice_info"> + <t t-call="l10n_es_edi_tbai.template_invoice_desglose"> + <t t-set="desglose" t-value="invoice_info['DesgloseFactura']"/> + </t> + </DesgloseFactura> + <DesgloseTipoOperacion t-else=""> + <t t-set="invoice_info" t-value="invoice_info['DesgloseTipoOperacion']"/> + <PrestacionServicios t-if="invoice_info.get('PrestacionServicios')"> + <t t-call="l10n_es_edi_tbai.template_invoice_desglose"> + <t t-set="desglose" t-value="invoice_info['PrestacionServicios']"/> + </t> + </PrestacionServicios> + <Entrega t-if="invoice_info.get('Entrega')"> + <t t-call="l10n_es_edi_tbai.template_invoice_desglose"> + <t t-set="desglose" t-value="invoice_info['Entrega']"/> + </t> + </Entrega> + </DesgloseTipoOperacion> + </TipoDesglose> + </template> + + <template id="template_invoice_desglose"> + <Sujeta t-if="desglose.get('Sujeta')"> + <t t-set="sujeta" t-value="desglose['Sujeta']"/> + <Exenta t-if="sujeta.get('Exenta')"> + <DetalleExenta t-foreach="sujeta['Exenta']['DetalleExenta']" t-as="exenta"> + <CausaExencion t-out="exenta['CausaExencion']"/> + <BaseImponible t-out="format_float(exenta['BaseImponible'])"/> + </DetalleExenta> + </Exenta> + <NoExenta t-if="sujeta.get('NoExenta')"> + <DetalleNoExenta t-if="desglose['S1']"> + <TipoNoExenta t-out="'S1'"/> + <DesgloseIVA> + <DetalleIVA t-foreach="desglose['S1']" t-as="detalle"> + <BaseImponible t-out="format_float(detalle['BaseImponible'])"/> + <TipoImpositivo t-out="format_float(detalle['TipoImpositivo'])"/> + <CuotaImpuesto t-out="format_float(detalle['CuotaRepercutida'])"/> + <TipoRecargoEquivalencia t-if="detalle.get('TipoRecargoEquivalencia')" t-out="format_float(detalle['TipoRecargoEquivalencia'])"/> + <CuotaRecargoEquivalencia t-if="detalle.get('CuotaRecargoEquivalencia')" t-out="format_float(detalle['CuotaRecargoEquivalencia'])"/> + <OperacionEnRecargoDeEquivalenciaORegimenSimplificado t-out="'S' if is_simplified else 'N'"/> + </DetalleIVA> + </DesgloseIVA> + </DetalleNoExenta> + <DetalleNoExenta t-if="desglose['S2']"> + <TipoNoExenta t-out="'S2'"/> + <DesgloseIVA> + <DetalleIVA t-foreach="desglose['S2']" t-as="detalle"> + <BaseImponible t-out="format_float(detalle['BaseImponible'])"/> + </DetalleIVA> + </DesgloseIVA> + </DetalleNoExenta> + </NoExenta> + </Sujeta> + <NoSujeta t-if="desglose.get('NoSujeta')"> + <t t-set="no_sujeta" t-value="desglose['NoSujeta']"/> + <DetalleNoSujeta> + <Causa>RL</Causa> + <!-- NOTE: Causa should be + 'OT' if 'the' ClaveRegimenIvaOpTrascendencia == 10 + 'RL' if 'some' ClaveRegimenIvaOpTrascendencia == 08 + BUT those are not supported yet--> + <Importe t-out="no_sujeta.get('ImportePorArticulos7_14_Otros')"/> + <Importe t-out="no_sujeta.get('ImporteTAIReglasLocalizacion')"/> + </DetalleNoSujeta> + </NoSujeta> + </template> + + <template id="template_digital_signature"> + <ds:Signature t-att-Id="dsig['signature_id']" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"> + <ds:SignedInfo> + <ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/> + <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/> + <ds:Reference t-att-Id="dsig['reference_uri']" Type="http://www.w3.org/2000/09/xmldsig#Object" URI=""> + <ds:Transforms> + <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/> + </ds:Transforms> + <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/> + <ds:DigestValue></ds:DigestValue> + </ds:Reference> + <ds:Reference Type="http://uri.etsi.org/01903#SignedProperties" t-attf-URI="##{dsig['sigproperties_id']}"> + <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/> + <ds:DigestValue></ds:DigestValue> + </ds:Reference> + <ds:Reference t-attf-URI="##{dsig['keyinfo_id']}"> + <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/> + <ds:DigestValue></ds:DigestValue> + </ds:Reference> + </ds:SignedInfo> + <ds:SignatureValue></ds:SignatureValue> + <ds:KeyInfo t-att-Id="dsig['keyinfo_id']"> + <ds:X509Data> + <ds:X509Certificate t-out="dsig['x509_certificate']"/> + </ds:X509Data> + <ds:KeyValue> + <ds:RSAKeyValue> + <ds:Modulus t-out="dsig['public_modulus']"/> + <ds:Exponent t-out="dsig['public_exponent']"/> + </ds:RSAKeyValue> + </ds:KeyValue> + </ds:KeyInfo> + <ds:Object> + <xades:QualifyingProperties xmlns:xades="http://uri.etsi.org/01903/v1.3.2#" t-attf-Target="##{dsig['signature_id']}"> + <xades:SignedProperties t-att-Id="dsig['sigproperties_id']"> + <xades:SignedSignatureProperties> + <xades:SigningTime t-out="dsig['iso_now']"/> + <xades:SigningCertificateV2> + <xades:Cert> + <xades:CertDigest> + <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/> + <ds:DigestValue t-out="dsig['sigcertif_digest']"/> + </xades:CertDigest> + <xades:IssuerSerial> + <ds:X509IssuerName t-out="dsig['x509_issuer_description']"/> + <ds:X509SerialNumber t-out="dsig['x509_serial_number']"/> + </xades:IssuerSerial> + </xades:Cert> + </xades:SigningCertificateV2> + <xades:SignaturePolicyIdentifier> + <xades:SignaturePolicyId> + <xades:SigPolicyId> + <xades:Identifier t-out="dsig['sigpolicy_url']"/> + </xades:SigPolicyId> + <xades:SigPolicyHash> + <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/> + <ds:DigestValue t-out="dsig['sigpolicy_digest']"/> + </xades:SigPolicyHash> + </xades:SignaturePolicyId> + </xades:SignaturePolicyIdentifier> + </xades:SignedSignatureProperties> + </xades:SignedProperties> + </xades:QualifyingProperties> + </ds:Object> + </ds:Signature> + </template> + </data> +</odoo> diff --git a/addons/l10n_es_edi_tbai/demo/certificates/Bizkaia-IZDesa2021.p12 b/addons/l10n_es_edi_tbai/demo/certificates/Bizkaia-IZDesa2021.p12 new file mode 100644 index 0000000000000000000000000000000000000000..ad2d5239630ec816df96ada589f4abb02788da1d Binary files /dev/null and b/addons/l10n_es_edi_tbai/demo/certificates/Bizkaia-IZDesa2021.p12 differ diff --git a/addons/l10n_es_edi_tbai/demo/certificates/araba_1234.p12 b/addons/l10n_es_edi_tbai/demo/certificates/araba_1234.p12 new file mode 100644 index 0000000000000000000000000000000000000000..4bba55798292f7f2fd95b8995a98c2e7ce21bc6f Binary files /dev/null and b/addons/l10n_es_edi_tbai/demo/certificates/araba_1234.p12 differ diff --git a/addons/l10n_es_edi_tbai/demo/certificates/gipuzkoa_IZDesa2021.p12 b/addons/l10n_es_edi_tbai/demo/certificates/gipuzkoa_IZDesa2021.p12 new file mode 100644 index 0000000000000000000000000000000000000000..ad2d5239630ec816df96ada589f4abb02788da1d Binary files /dev/null and b/addons/l10n_es_edi_tbai/demo/certificates/gipuzkoa_IZDesa2021.p12 differ diff --git a/addons/l10n_es_edi_tbai/demo/demo_company.xml b/addons/l10n_es_edi_tbai/demo/demo_company.xml new file mode 100644 index 0000000000000000000000000000000000000000..118bae383ffe30d87146a616730db0ab8ce5921a --- /dev/null +++ b/addons/l10n_es_edi_tbai/demo/demo_company.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="l10n_es.demo_company_es" model="res.company"> + <field name="l10n_es_tbai_tax_agency">gipuzkoa</field> + </record> +</odoo> diff --git a/addons/l10n_es_edi_tbai/demo/demo_res_partner.xml b/addons/l10n_es_edi_tbai/demo/demo_res_partner.xml new file mode 100644 index 0000000000000000000000000000000000000000..aff6913db4c521b964a746447dddad46eb8dde4b --- /dev/null +++ b/addons/l10n_es_edi_tbai/demo/demo_res_partner.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="l10n_es_tbai_partner_spanish_filemon" model="res.partner"> + <field name="name">Mortadelas Filemón (ES)</field> + <field name="is_company" eval="True"/> + <field name="street">Calle Calipso 18</field> + <field name="city">Mairena del Aljarafe</field> + <field name="zip">41927</field> + <field name="country_id" ref="base.es"/> + <field name="vat">ES86762599K</field> + </record> + <record id="l10n_es_tbai_partner_european_lagaffe" model="res.partner"> + <field name="name">Frites Lagaffe (BE)</field> + <field name="is_company" eval="True"/> + <field name="street">321 Rue sans souci</field> + <field name="city">Bruxelles</field> + <field name="zip">1050</field> + <field name="country_id" ref="base.be"/> + <field name="vat">BE789923062</field> + </record> +</odoo> diff --git a/addons/l10n_es_edi_tbai/models/__init__.py b/addons/l10n_es_edi_tbai/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..61e9ecea3cb0894004a215f4466735a8d4b46d9e --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_edi_document +from . import account_edi_format +from . import account_move +from . import ir_attachment +from . import l10n_es_edi_tbai_certificate +from . import res_company +from . import res_config_settings +from . import xml_utils +from . import l10n_es_edi_tbai_agencies diff --git a/addons/l10n_es_edi_tbai/models/account_edi_document.py b/addons/l10n_es_edi_tbai/models/account_edi_document.py new file mode 100644 index 0000000000000000000000000000000000000000..22f215e0352de7b5094d35c5ef7da6e6a89e4891 --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/account_edi_document.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + +class AccountEdiDocument(models.Model): + _inherit = 'account.edi.document' + + def _prepare_jobs(self): + """ + If there is a job to process that may already be part of the chain (posted invoice that timeout'ed), + Re-places it at the beginning of the list. + """ + # EXTENDS account_edi + jobs = super()._prepare_jobs() + if len(jobs) > 1: + move_first_index = 0 + for index, job in enumerate(jobs): + documents = job['documents'] + if any(d.edi_format_id.code == 'es_tbai' and d.state == 'to_send' and d.move_id.l10n_es_tbai_chain_index for d in documents): + move_first_index = index + break + jobs = [jobs[move_first_index]] + jobs[:move_first_index] + jobs[move_first_index + 1:] + + return jobs diff --git a/addons/l10n_es_edi_tbai/models/account_edi_format.py b/addons/l10n_es_edi_tbai/models/account_edi_format.py new file mode 100644 index 0000000000000000000000000000000000000000..0c44b0e0d5e2b2b0e9f951364ffad616840f85d6 --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/account_edi_format.py @@ -0,0 +1,612 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import gzip +import json +from base64 import b64encode +from datetime import datetime +from re import sub as regex_sub +from uuid import uuid4 +from markupsafe import Markup, escape + +import requests +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.x509.oid import NameOID +from lxml import etree +from pytz import timezone +from requests.exceptions import RequestException + +from odoo import _, models, release +from odoo.addons.l10n_es_edi_sii.models.account_edi_format import PatchedHTTPAdapter +from odoo.addons.l10n_es_edi_tbai.models.l10n_es_edi_tbai_agencies import get_key +from odoo.addons.l10n_es_edi_tbai.models.xml_utils import ( + NS_MAP, bytes_as_block, calculate_references_digests, + cleanup_xml_signature, fill_signature, int_as_bytes) +from odoo.exceptions import UserError, ValidationError +from odoo.tools import get_lang +from odoo.tools.float_utils import float_repr +from odoo.tools.xml_utils import cleanup_xml_node, validate_xml_from_attachment + + +class AccountEdiFormat(models.Model): + _inherit = 'account.edi.format' + + # ------------------------------------------------------------------------- + # OVERRIDES & EXTENSIONS + # ------------------------------------------------------------------------- + + def _needs_web_services(self): + # EXTENDS account_edi + return self.code == 'es_tbai' or super()._needs_web_services() + + def _is_enabled_by_default_on_journal(self, journal): + """ Disable SII by default on a new journal when tbai is installed""" + if self.code != 'es_sii': + return super()._is_enabled_by_default_on_journal(journal) + return False + + def _is_compatible_with_journal(self, journal): + # EXTENDS account_edi + if self.code != 'es_tbai': + return super()._is_compatible_with_journal(journal) + + return journal.country_code == 'ES' and journal.type == 'sale' + + def _get_move_applicability(self, move): + # EXTENDS account_edi + self.ensure_one() + if self.code != 'es_tbai' or move.country_code != 'ES' \ + or not move.l10n_es_tbai_is_required \ + or move.move_type not in ('out_invoice', 'out_refund'): + return super()._get_move_applicability(move) + + return { + 'post': self._l10n_es_tbai_post_invoice_edi, + 'cancel': self._l10n_es_tbai_cancel_invoice_edi, + 'edi_content': self._l10n_es_tbai_get_invoice_content_edi, + } + + def _check_move_configuration(self, invoice): + # EXTENDS account_edi + errors = super()._check_move_configuration(invoice) + + if self.code != 'es_tbai' or invoice.country_code != 'ES': + return errors + + # Ensure a certificate is available. + if not invoice.company_id.l10n_es_edi_certificate_id: + errors.append(_("Please configure the certificate for TicketBAI/SII.")) + + # Ensure a tax agency is available. + if not invoice.company_id.mapped('l10n_es_tbai_tax_agency')[0]: + errors.append(_("Please specify a tax agency on your company for TicketBAI.")) + + # Check the refund reason + if invoice.move_type == 'out_refund': + if not invoice.l10n_es_tbai_refund_reason: + raise ValidationError(_('Refund reason must be specified (TicketBAI)')) + if invoice._is_l10n_es_tbai_simplified(): + if invoice.l10n_es_tbai_refund_reason != 'R5': + raise ValidationError(_('Refund reason must be R5 for simplified invoices (TicketBAI)')) + else: + if invoice.l10n_es_tbai_refund_reason == 'R5': + raise ValidationError(_('Refund reason cannot be R5 for non-simplified invoices (TicketBAI)')) + + return errors + + def _l10n_es_tbai_post_invoice_edi(self, invoice): + # EXTENDS account_edi + if self.code != 'es_tbai': + return super()._post_invoice_edi(invoice) + + # Chain integrity check: chain head must have been REALLY posted (not timeout'ed) + # - If called from a cron, then the re-ordering of jobs should prevent this from triggering + # - If called manually, then the user will see this error pop up when it triggers + chain_head = invoice.company_id._get_l10n_es_tbai_last_posted_invoice() + if chain_head and chain_head != invoice and not chain_head._l10n_es_tbai_is_in_chain(): + raise UserError(f"TicketBAI: Cannot post invoice while chain head ({chain_head.name}) has not been posted") + + # Generate the XML values. + inv_dict = self._get_l10n_es_tbai_invoice_xml(invoice) + if 'error' in inv_dict[invoice]: + return inv_dict # XSD validation failed, return result dict + + # Store the XML as attachment to ensure it is never lost (even in case of timeout error) + inv_xml = inv_dict[invoice]['xml_file'] + invoice._update_l10n_es_tbai_submitted_xml(xml_doc=inv_xml, cancel=False) + + # Assign unique 'chain index' from dedicated sequence + if not invoice.l10n_es_tbai_chain_index: + invoice.l10n_es_tbai_chain_index = invoice.company_id._get_l10n_es_tbai_next_chain_index() + + # Call the web service and get response + res = self._l10n_es_tbai_post_to_web_service(invoice, inv_xml) + + # SUCCESS + if res[invoice].get('success'): + # Create attachment + attachment = self.env['ir.attachment'].create({ + 'name': invoice.name + '_post.xml', + 'datas': invoice.l10n_es_tbai_post_xml, + 'mimetype': 'application/xml', + 'res_id': invoice.id, + 'res_model': 'account.move', + }) + + # Post attachment to chatter and save it as EDI document + test_suffix = '(test mode)' if invoice.company_id.l10n_es_edi_test_env else '' + invoice.with_context(no_new_invoice=True).message_post( + body=Markup("<pre>TicketBAI: posted emission XML {test_suffix}\n{message}</pre>").format( + test_suffix=test_suffix, message=res[invoice]['message'] + ), + attachment_ids=[attachment.id], + ) + res[invoice]['attachment'] = attachment + + # FAILURE + # NOTE: 'warning' means timeout so absolutely keep the XML and chain index + elif res[invoice].get('blocking_level') == 'error': + invoice._update_l10n_es_tbai_submitted_xml(xml_doc=None, cancel=False) # deletes XML + # delete index (avoids re-trying same XML and chaining off of it) + invoice.l10n_es_tbai_chain_index = False + + return res + + def _l10n_es_tbai_cancel_invoice_edi(self, invoice): + # EXTENDS account_edi + if self.code != 'es_tbai': + return super()._cancel_invoice_edi(invoice) + + # Generate the XML values. + cancel_dict = self._get_l10n_es_tbai_invoice_xml(invoice, cancel=True) + if 'error' in cancel_dict[invoice]: + return cancel_dict # XSD validation failed, return result dict + + # Store the XML as attachment to ensure it is never lost (even in case of timeout error) + cancel_xml = cancel_dict[invoice]['xml_file'] + invoice._update_l10n_es_tbai_submitted_xml(xml_doc=cancel_xml, cancel=True) + + # Call the web service and get response + res = self._l10n_es_tbai_post_to_web_service(invoice, cancel_xml, cancel=True) + + # SUCCESS + if res[invoice].get('success'): + # Create attachment + attachment = self.env['ir.attachment'].create({ + 'name': invoice.name + '_cancel.xml', + 'datas': invoice.l10n_es_tbai_cancel_xml, + 'mimetype': 'application/xml', + 'res_id': invoice.id, + 'res_model': 'account.move', + }) + + # Post attachment to chatter + test_suffix = '(test mode)' if invoice.company_id.l10n_es_edi_test_env else '' + invoice.with_context(no_new_invoice=True).message_post( + body=Markup("<pre>TicketBAI: posted cancellation XML {test_suffix}\n{message}</pre>").format( + test_suffix=test_suffix, message=res[invoice]['message'] + ), + attachment_ids=[attachment.id], + ) + + # FAILURE + # NOTE: 'warning' means timeout so absolutely keep the XML and chain index + elif res[invoice].get('blocking_level') == 'error': + invoice._update_l10n_es_tbai_submitted_xml(xml_doc=None, cancel=True) # will need to be re-created + + return res + + # ------------------------------------------------------------------------- + # XML DOCUMENT + # ------------------------------------------------------------------------- + + def _l10n_es_tbai_validate_xml_with_xsd(self, xml_doc, cancel, tax_agency): + xsd_name = get_key(tax_agency, 'xsd_name')['cancel' if cancel else 'post'] + + try: + validate_xml_from_attachment(self.env, xml_doc, xsd_name, prefix='l10n_es_edi_tbai') + except UserError as e: + return {'error': escape(str(e)), 'blocking_level': 'error'} + return {} + + def _l10n_es_tbai_get_invoice_content_edi(self, invoice): + cancel = invoice.edi_state in ('to_cancel', 'cancelled') + xml_tree = self._get_l10n_es_tbai_invoice_xml(invoice, cancel)[invoice]['xml_file'] + return etree.tostring(xml_tree) + + def _get_l10n_es_tbai_invoice_xml(self, invoice, cancel=False): + # If previously generated XML was posted and not rejected (success or timeout), reuse it + doc = invoice._get_l10n_es_tbai_submitted_xml(cancel) + if doc is not None: + return {invoice: {'xml_file': doc}} + + # Otherwise, generate a new XML + values = { + **invoice.company_id._get_l10n_es_tbai_license_dict(), + **self._l10n_es_tbai_get_header_values(invoice), + **self._l10n_es_tbai_get_subject_values(invoice, cancel), + **self._l10n_es_tbai_get_invoice_values(invoice, cancel), + **self._l10n_es_tbai_get_trail_values(invoice, cancel), + 'is_emission': not cancel, + 'datetime_now': datetime.now(tz=timezone('Europe/Madrid')), + 'format_date': lambda d: datetime.strftime(d, '%d-%m-%Y'), + 'format_time': lambda d: datetime.strftime(d, '%H:%M:%S'), + 'format_float': lambda f: float_repr(f, precision_digits=2), + } + template_name = 'l10n_es_edi_tbai.template_invoice_main' + ('_cancel' if cancel else '_post') + xml_str = self.env['ir.qweb']._render(template_name, values) + xml_doc = cleanup_xml_node(xml_str, remove_blank_nodes=False) + xml_doc = self._l10n_es_tbai_sign_invoice(invoice, xml_doc) + res = {invoice: {'xml_file': xml_doc}} + + # Optional check using the XSD + res[invoice].update(self._l10n_es_tbai_validate_xml_with_xsd(xml_doc, cancel, invoice.company_id.l10n_es_tbai_tax_agency)) + return res + + def _l10n_es_tbai_get_header_values(self, invoice): + return { + 'tbai_version': self.L10N_ES_TBAI_VERSION, + 'odoo_version': release.version, + } + + def _l10n_es_tbai_get_subject_values(self, invoice, cancel): + # === SENDER (EMISOR) === + sender = invoice.company_id + values = { + 'sender_vat': sender.vat[2:] if sender.vat.startswith('ES') else sender.vat, + 'sender': sender, + } + if cancel: + return values # cancellation invoices do not specify recipients (they stay the same) + + # NOTE: TicketBai supports simplified invoices WITH recipients but we don't for now (we should for POS) + # NOTE: TicketBAI credit notes for simplified invoices are ALWAYS simplified BUT can have a recipient even if invoice doesn't + if invoice._is_l10n_es_tbai_simplified(): + return values # do not set 'recipient' unless there is an actual recipient (used as condition in template) + + # === RECIPIENTS (DESTINATARIOS) === + nif = False + alt_id_country = False + partner = invoice.commercial_partner_id + alt_id_number = partner.vat or 'NO_DISPONIBLE' + alt_id_type = "" + if (not partner.country_id or partner.country_id.code == 'ES') and partner.vat: + # ES partner with VAT. + nif = partner.vat[2:] if partner.vat.startswith('ES') else partner.vat + elif partner.country_id.code in self.env.ref('base.europe').country_ids.mapped('code'): + # European partner + alt_id_type = '02' + else: + # Non-european partner + if partner.vat: + alt_id_type = '04' + else: + alt_id_type = '06' + if partner.country_id: + alt_id_country = partner.country_id.code + + values_dest = { + 'nif': nif, + 'alt_id_country': alt_id_country, + 'alt_id_number': alt_id_number, + 'alt_id_type': alt_id_type, + 'partner': partner, + 'partner_address': ', '.join(filter(None, [partner.street, partner.street2, partner.city])), + } + + values.update({ + 'recipient': values_dest, + }) + return values + + def _l10n_es_tbai_get_invoice_values(self, invoice, cancel): + # Header + values = {'invoice': invoice} + if cancel: + return values + + # Credit notes (factura rectificativa) + # NOTE values below would have to be adapted for purchase invoices (Bizkaia LROE) + values['is_refund'] = invoice.move_type == 'out_refund' + if values['is_refund']: + values['credit_note_code'] = invoice.l10n_es_tbai_refund_reason + values['credit_note_invoice'] = invoice.reversed_entry_id + + # Lines (detalle) + refund_sign = (1 if values['is_refund'] else -1) + values['invoice_lines'] = [{ + 'line': line, + 'discount': (discount := (line.balance / (1 - line.discount / 100) * line.discount / 100)) * refund_sign, + 'unit_price': (line.balance + discount) / line.quantity * refund_sign, + 'total': line.price_total * abs(line.balance / line.amount_currency if line.amount_currency != 0 else 1) * -refund_sign if not any( + [t.l10n_es_type == 'sujeto_isp' for t in line.tax_ids]) else abs(line.balance) * -refund_sign * (-1 if line.price_total < 0 else 1), + 'description': regex_sub(r'[^0-9a-zA-Z ]', '', line.name)[:250], # only keep characters allowed in description + } for line in invoice.invoice_line_ids.filtered(lambda line: line.display_type not in ('line_section', 'line_note'))] + + # Tax details (desglose) + importe_total, desglose = self._l10n_es_tbai_get_importe_desglose(invoice) + values['amount_total'] = importe_total + values['invoice_info'] = desglose + + # Regime codes (ClaveRegimenEspecialOTrascendencia) + # NOTE there's 11 more codes to implement, also there can be up to 3 in total + # See https://www.gipuzkoa.eus/documents/2456431/13761128/Anexo+I.pdf/2ab0116c-25b4-f16a-440e-c299952d683d + com_partner = invoice.commercial_partner_id + if not com_partner.country_id or com_partner.country_id.code in self.env.ref('base.europe').country_ids.mapped('code'): + values['regime_key'] = ['01'] + else: + values['regime_key'] = ['02'] + + if invoice._is_l10n_es_tbai_simplified(): + values['regime_key'].append(52) # code for simplified invoices + + return values + + def _l10n_es_tbai_get_importe_desglose(self, invoice): + com_partner = invoice.commercial_partner_id + sign = -1 if invoice.move_type in ('out_refund', 'in_refund') else 1 + if com_partner.country_id.code in ('ES', False) and not (com_partner.vat or '').startswith("ESN"): + tax_details_info_vals = self._l10n_es_edi_get_invoices_tax_details_info(invoice) + desglose = {'DesgloseFactura': tax_details_info_vals['tax_details_info']} + desglose['DesgloseFactura'].update({'S1': tax_details_info_vals['S1_list'], + 'S2': tax_details_info_vals['S2_list']}) + importe_total = round(sign * ( + tax_details_info_vals['tax_details']['base_amount'] + + tax_details_info_vals['tax_details']['tax_amount'] + - tax_details_info_vals['tax_amount_retention'] + ), 2) + else: + tax_details_info_service_vals = self._l10n_es_edi_get_invoices_tax_details_info( + invoice, + filter_invl_to_apply=lambda x: any(t.tax_scope == 'service' for t in x.tax_ids) + ) + tax_details_info_consu_vals = self._l10n_es_edi_get_invoices_tax_details_info( + invoice, + filter_invl_to_apply=lambda x: any(t.tax_scope == 'consu' for t in x.tax_ids) + ) + desglose = {} + if tax_details_info_service_vals['tax_details_info']: + desglose.setdefault('DesgloseTipoOperacion', {}) + desglose['DesgloseTipoOperacion']['PrestacionServicios'] = tax_details_info_service_vals['tax_details_info'] + desglose['TipoDesglose']['DesgloseTipoOperacion']['PrestacionServicios'].update( + {'S1': tax_details_info_service_vals['S1_list'], + 'S2': tax_details_info_service_vals['S2_list']}) + + if tax_details_info_consu_vals['tax_details_info']: + desglose.setdefault('DesgloseTipoOperacion', {}) + desglose['DesgloseTipoOperacion']['Entrega'] = tax_details_info_consu_vals['tax_details_info'] + desglose['DesgloseTipoOperacion']['Entrega'].update( + {'S1': tax_details_info_consu_vals['S1_list'], + 'S2': tax_details_info_consu_vals['S2_list']}) + importe_total = round(sign * ( + tax_details_info_service_vals['tax_details']['base_amount'] + + tax_details_info_service_vals['tax_details']['tax_amount'] + - tax_details_info_service_vals['tax_amount_retention'] + + tax_details_info_consu_vals['tax_details']['base_amount'] + + tax_details_info_consu_vals['tax_details']['tax_amount'] + - tax_details_info_consu_vals['tax_amount_retention'] + ), 2) + return importe_total, desglose + + def _l10n_es_tbai_get_trail_values(self, invoice, cancel): + prev_invoice = invoice.company_id._get_l10n_es_tbai_last_posted_invoice(invoice) + # NOTE: assumtion that last posted == previous works because XML is generated on post + if prev_invoice and not cancel: + return { + 'chain_prev_invoice': prev_invoice + } + else: + return {} + + def _l10n_es_tbai_sign_invoice(self, invoice, xml_root): + company = invoice.company_id + cert_private, cert_public = company.l10n_es_edi_certificate_id._get_key_pair() + public_key = cert_public.public_key() + + # Identifiers + document_id = "Document-" + str(uuid4()) + signature_id = "Signature-" + document_id + keyinfo_id = "KeyInfo-" + document_id + sigproperties_id = "SignatureProperties-" + document_id + + # Render digital signature scaffold from QWeb + common_name = cert_public.issuer.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value + org_unit = cert_public.issuer.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME)[0].value + org_name = cert_public.issuer.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)[0].value + country_name = cert_public.issuer.get_attributes_for_oid(NameOID.COUNTRY_NAME)[0].value + values = { + 'dsig': { + 'document_id': document_id, + 'x509_certificate': bytes_as_block(cert_public.public_bytes(encoding=serialization.Encoding.DER)), + 'public_modulus': bytes_as_block(int_as_bytes(public_key.public_numbers().n)), + 'public_exponent': bytes_as_block(int_as_bytes(public_key.public_numbers().e)), + 'iso_now': datetime.now().isoformat(), + 'keyinfo_id': keyinfo_id, + 'signature_id': signature_id, + 'sigproperties_id': sigproperties_id, + 'reference_uri': "Reference-" + document_id, + 'sigpolicy_url': get_key(company.l10n_es_tbai_tax_agency, 'sigpolicy_url'), + 'sigpolicy_digest': get_key(company.l10n_es_tbai_tax_agency, 'sigpolicy_digest'), + 'sigcertif_digest': b64encode(cert_public.fingerprint(hashes.SHA256())).decode(), + 'x509_issuer_description': 'CN={}, OU={}, O={}, C={}'.format(common_name, org_unit, org_name, country_name), + 'x509_serial_number': cert_public.serial_number, + } + } + xml_sig_str = self.env['ir.qweb']._render('l10n_es_edi_tbai.template_digital_signature', values) + xml_sig = cleanup_xml_signature(xml_sig_str) + + # Complete document with signature template + xml_root.append(xml_sig) + + # Compute digest values for references + calculate_references_digests(xml_sig.find("SignedInfo", namespaces=NS_MAP)) + + # Sign (writes into SignatureValue) + fill_signature(xml_sig, cert_private) + + return xml_root + + # ------------------------------------------------------------------------- + # WEB SERVICE CALLS + # ------------------------------------------------------------------------- + + def _l10n_es_tbai_post_to_web_service(self, invoice, invoice_xml, cancel=False): + company = invoice.company_id + + try: + # Call the web service, retrieve and parse response + success, message, response_xml = self._l10n_es_tbai_post_to_agency( + self.env, company.l10n_es_tbai_tax_agency, invoice, invoice_xml, cancel) + except (ValueError, RequestException) as e: + # In case of timeout / request exception, return warning + return {invoice: { + 'error': str(e), + 'blocking_level': 'warning', + 'response': None, + }} + + if success: + return {invoice: { + 'success': True, + 'message': message, + 'response': response_xml, + }} + else: + return {invoice: { + 'error': message, + 'blocking_level': 'error', + 'response': response_xml, + }} + + # ------------------------------------------------------------------------- + # WEB SERVICE METHODS + # ------------------------------------------------------------------------- + # Provides helper methods for interacting with the Basque country's TicketBai servers. + + L10N_ES_TBAI_VERSION = 1.2 + + def _l10n_es_tbai_post_to_agency(self, env, agency, invoice, invoice_xml, cancel=False): + if agency in ('araba', 'gipuzkoa'): + post_method, process_method = self._l10n_es_tbai_prepare_post_params_ar_gi, self._l10n_es_tbai_process_post_response_ar_gi + elif agency == 'bizkaia': + post_method, process_method = self._l10n_es_tbai_prepare_post_params_bi, self._l10n_es_tbai_process_post_response_bi + params = post_method(env, agency, invoice, invoice_xml, cancel) + response = self._l10n_es_tbai_send_request_to_agency(timeout=10, **params) + return process_method(env, response) + + def _l10n_es_tbai_send_request_to_agency(self, *args, **kwargs): + session = requests.Session() + session.cert = kwargs.pop('pkcs12_data') + session.mount("https://", PatchedHTTPAdapter()) + return session.request('post', *args, **kwargs) + + def _l10n_es_tbai_prepare_post_params_ar_gi(self, env, agency, invoice, invoice_xml, cancel=False): + """Web service parameters for Araba and Gipuzkoa.""" + company = invoice.company_id + return { + 'url': get_key(agency, 'cancel_url_' if cancel else 'post_url_', company.l10n_es_edi_test_env), + 'headers': {"Content-Type": "application/xml; charset=utf-8"}, + 'pkcs12_data': company.l10n_es_edi_certificate_id, + 'data': etree.tostring(invoice_xml, encoding='UTF-8'), + } + + def _l10n_es_tbai_process_post_response_ar_gi(self, env, response): + """Government response processing for Araba and Gipuzkoa.""" + try: + response_xml = etree.fromstring(response.content) + except etree.XMLSyntaxError as e: + return False, e, None + + # Error management + message = '' + already_received = False + # Get message in basque if env is in basque + msg_node_name = 'Azalpena' if get_lang(env).code == 'eu_ES' else 'Descripcion' + for xml_res_node in response_xml.findall(r'.//ResultadosValidacion'): + message_code = xml_res_node.find('Codigo').text + message += message_code + ": " + xml_res_node.find(msg_node_name).text + "\n" + if message_code in ('005', '019'): + already_received = True # error codes 5/19 mean XML was already received with that sequence + response_code = int(response_xml.find(r'.//Estado').text) + response_success = (response_code == 0) or already_received + + return response_success, message, response_xml + + def _l10n_es_tbai_prepare_post_params_bi(self, env, agency, invoice, invoice_xml, cancel=False): + """Web service parameters for Bizkaia.""" + sender = invoice.company_id + lroe_values = { + 'is_emission': not cancel, + 'sender': sender, + 'sender_vat': sender.vat[2:] if sender.vat.startswith('ES') else sender.vat, + 'tbai_b64_list': [b64encode(etree.tostring(invoice_xml, encoding="UTF-8")).decode()], + 'fiscal_year': str(invoice.date.year), + } + lroe_str = env['ir.qweb']._render('l10n_es_edi_tbai.template_LROE_240_main', lroe_values) + lroe_xml = cleanup_xml_node(lroe_str) + lroe_str = etree.tostring(lroe_xml, encoding="UTF-8") + lroe_bytes = gzip.compress(lroe_str) + + company = invoice.company_id + return { + 'url': get_key(agency, 'cancel_url_' if cancel else 'post_url_', company.l10n_es_edi_test_env), + 'headers': { + 'Accept-Encoding': 'gzip', + 'Content-Encoding': 'gzip', + 'Content-Length': str(len(lroe_str)), + 'Content-Type': 'application/octet-stream', + 'eus-bizkaia-n3-version': '1.0', + 'eus-bizkaia-n3-content-type': 'application/xml', + 'eus-bizkaia-n3-data': json.dumps({ + 'con': 'LROE', + 'apa': '1.1', + 'inte': { + 'nif': lroe_values['sender_vat'], + 'nrs': sender.name, + }, + 'drs': { + 'mode': '240', + # NOTE: modelo 140 for freelancers (in/out invoices) + # modelo 240 for legal entities (lots of account moves ?) + 'ejer': str(invoice.date.year), + } + }), + }, + 'pkcs12_data': invoice.company_id.l10n_es_edi_certificate_id, + 'data': lroe_bytes, + } + + def _l10n_es_tbai_process_post_response_bi(self, env, response): + """Government response processing for Bizkaia.""" + # GLOBAL STATUS (LROE) + response_messages = [] + response_success = True + if response.headers['eus-bizkaia-n3-tipo-respuesta'] != "Correcto": + code = response.headers['eus-bizkaia-n3-codigo-respuesta'] + response_messages.append(code + ': ' + response.headers['eus-bizkaia-n3-mensaje-respuesta']) + response_success = False + + response_data = response.content + response_xml = None + if response_data: + try: + response_xml = etree.fromstring(response_data) + except etree.XMLSyntaxError as e: + response_success = False + response_messages.append(str(e)) + else: + response_success = False + response_messages.append(_('No XML response received from LROE.')) + + # INVOICE STATUS (only one in batch) + # Get message in basque if env is in basque + if response_xml is not None: + msg_node_name = 'DescripcionErrorRegistro' + ('EU' if get_lang(env).code == 'eu_ES' else 'ES') + invoice_success = response_xml.find(r'.//EstadoRegistro').text == "Correcto" + if not invoice_success: + invoice_code = response_xml.find(r'.//CodigoErrorRegistro').text + if invoice_code == "B4_2000003": # already received + invoice_success = True + response_messages.append(invoice_code + ": " + (response_xml.find(rf'.//{msg_node_name}').text or '')) + + return response_success and invoice_success, '<br/>'.join(response_messages), response_xml diff --git a/addons/l10n_es_edi_tbai/models/account_move.py b/addons/l10n_es_edi_tbai/models/account_move.py new file mode 100644 index 0000000000000000000000000000000000000000..f5be1beab4f808cfa0b4e88f6fdf29e36df7beb5 --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/account_move.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from base64 import b64decode, b64encode +from datetime import datetime +from re import sub as regex_sub + +from lxml import etree +from odoo import _, api, fields, models +from odoo.addons.l10n_es_edi_tbai.models.l10n_es_edi_tbai_agencies import get_key +from odoo.exceptions import UserError + +L10N_ES_TBAI_CRC8_TABLE = [ + 0x00, 0x07, 0x0E, 0x09, 0x1C, 0x1B, 0x12, 0x15, 0x38, 0x3F, 0x36, 0x31, 0x24, 0x23, 0x2A, 0x2D, + 0x70, 0x77, 0x7E, 0x79, 0x6C, 0x6B, 0x62, 0x65, 0x48, 0x4F, 0x46, 0x41, 0x54, 0x53, 0x5A, 0x5D, + 0xE0, 0xE7, 0xEE, 0xE9, 0xFC, 0xFB, 0xF2, 0xF5, 0xD8, 0xDF, 0xD6, 0xD1, 0xC4, 0xC3, 0xCA, 0xCD, + 0x90, 0x97, 0x9E, 0x99, 0x8C, 0x8B, 0x82, 0x85, 0xA8, 0xAF, 0xA6, 0xA1, 0xB4, 0xB3, 0xBA, 0xBD, + 0xC7, 0xC0, 0xC9, 0xCE, 0xDB, 0xDC, 0xD5, 0xD2, 0xFF, 0xF8, 0xF1, 0xF6, 0xE3, 0xE4, 0xED, 0xEA, + 0xB7, 0xB0, 0xB9, 0xBE, 0xAB, 0xAC, 0xA5, 0xA2, 0x8F, 0x88, 0x81, 0x86, 0x93, 0x94, 0x9D, 0x9A, + 0x27, 0x20, 0x29, 0x2E, 0x3B, 0x3C, 0x35, 0x32, 0x1F, 0x18, 0x11, 0x16, 0x03, 0x04, 0x0D, 0x0A, + 0x57, 0x50, 0x59, 0x5E, 0x4B, 0x4C, 0x45, 0x42, 0x6F, 0x68, 0x61, 0x66, 0x73, 0x74, 0x7D, 0x7A, + 0x89, 0x8E, 0x87, 0x80, 0x95, 0x92, 0x9B, 0x9C, 0xB1, 0xB6, 0xBF, 0xB8, 0xAD, 0xAA, 0xA3, 0xA4, + 0xF9, 0xFE, 0xF7, 0xF0, 0xE5, 0xE2, 0xEB, 0xEC, 0xC1, 0xC6, 0xCF, 0xC8, 0xDD, 0xDA, 0xD3, 0xD4, + 0x69, 0x6E, 0x67, 0x60, 0x75, 0x72, 0x7B, 0x7C, 0x51, 0x56, 0x5F, 0x58, 0x4D, 0x4A, 0x43, 0x44, + 0x19, 0x1E, 0x17, 0x10, 0x05, 0x02, 0x0B, 0x0C, 0x21, 0x26, 0x2F, 0x28, 0x3D, 0x3A, 0x33, 0x34, + 0x4E, 0x49, 0x40, 0x47, 0x52, 0x55, 0x5C, 0x5B, 0x76, 0x71, 0x78, 0x7F, 0x6A, 0x6D, 0x64, 0x63, + 0x3E, 0x39, 0x30, 0x37, 0x22, 0x25, 0x2C, 0x2B, 0x06, 0x01, 0x08, 0x0F, 0x1A, 0x1D, 0x14, 0x13, + 0xAE, 0xA9, 0xA0, 0xA7, 0xB2, 0xB5, 0xBC, 0xBB, 0x96, 0x91, 0x98, 0x9F, 0x8A, 0x8D, 0x84, 0x83, + 0xDE, 0xD9, 0xD0, 0xD7, 0xC2, 0xC5, 0xCC, 0xCB, 0xE6, 0xE1, 0xE8, 0xEF, 0xFA, 0xFD, 0xF4, 0xF3 +] + +class AccountMove(models.Model): + _inherit = 'account.move' + + # Stored fields + l10n_es_tbai_chain_index = fields.Integer( + string="TicketBAI chain index", + help="Invoice index in chain, set if and only if an in-chain XML was submitted and did not error", + copy=False, readonly=True, + ) + + # Stored XML Binaries + l10n_es_tbai_post_xml = fields.Binary( + attachment=True, readonly=True, copy=False, + string="Submission XML", + help="Submission XML sent to TicketBAI. Kept if accepted or no response (timeout), cleared otherwise.", + ) + l10n_es_tbai_cancel_xml = fields.Binary( + attachment=True, readonly=True, copy=False, + string="Cancellation XML", + help="Cancellation XML sent to TicketBAI. Kept if accepted or no response (timeout), cleared otherwise.", + ) + + # Non-stored fields + l10n_es_tbai_is_required = fields.Boolean( + string="TicketBAI required", + help="Is the Basque EDI (TicketBAI) needed ?", + compute="_compute_l10n_es_tbai_is_required", + ) + + # Optional fields + l10n_es_tbai_refund_reason = fields.Selection( + selection=[ + ('R1', "R1: Art. 80.1, 80.2, 80.6 and rights founded error"), + ('R2', "R2: Art. 80.3"), + ('R3', "R3: Art. 80.4"), + ('R4', "R4: Art. 80 - other"), + ('R5', "R5: Factura rectificativa en facturas simplificadas"), + ], + string="Invoice Refund Reason Code (TicketBai)", + help="BOE-A-1992-28740. Ley 37/1992, de 28 de diciembre, del Impuesto sobre el " + "Valor Añadido. ArtÃculo 80. Modificación de la base imponible.", + copy=False, + ) + + # ------------------------------------------------------------------------- + # API-DECORATED & EXTENDED METHODS + # ------------------------------------------------------------------------- + + @api.depends('move_type', 'company_id') + def _compute_l10n_es_tbai_is_required(self): + for move in self: + move.l10n_es_tbai_is_required = move.is_sale_document() \ + and move.country_code == 'ES' \ + and move.company_id.l10n_es_tbai_tax_agency + + @api.depends('state', 'edi_document_ids.state') + def _compute_show_reset_to_draft_button(self): + # EXTENDS account_edi account.move + super()._compute_show_reset_to_draft_button() + + for move in self: + if move.l10n_es_tbai_chain_index: + move.show_reset_to_draft_button = False + + def button_draft(self): + # EXTENDS account account.move + for move in self: + if move.l10n_es_tbai_chain_index and not move.edi_state == 'cancelled': + # NOTE this last condition (state is cancelled) is there because + # _postprocess_cancel_edi_results calls button_draft before + # calling button_cancel. Draft button does not appear for user. + raise UserError(_("You cannot reset to draft an entry that has been posted to TicketBAI's chain")) + super().button_draft() + + @api.ondelete(at_uninstall=False) + def _l10n_es_tbai_unlink_except_in_chain(self): + # Prevent deleting moves that are part of the TicketBAI chain + if not self._context.get('force_delete') and any(m.l10n_es_tbai_chain_index for m in self): + raise UserError(_('You cannot delete a move that has a TicketBAI chain id.')) + + # ------------------------------------------------------------------------- + # HELPER METHODS + # ------------------------------------------------------------------------- + + def _l10n_es_tbai_is_in_chain(self): + """ + True iff invoice has been posted to the chain and confirmed by govt. + Note that cancelled invoices remain part of the chain. + """ + tbai_doc_ids = self.edi_document_ids.filtered(lambda d: d.edi_format_id.code == 'es_tbai') + return self.l10n_es_tbai_is_required \ + and len(tbai_doc_ids) > 0 \ + and not any(tbai_doc_ids.filtered(lambda d: d.state == 'to_send')) + + def _get_l10n_es_tbai_sequence_and_number(self): + """Get the TicketBAI sequence a number values for this invoice.""" + self.ensure_one() + sequence, number = self.name.rsplit('/', 1) # NOTE non-decimal characters should not appear in the number + sequence = regex_sub(r"[^0-9A-Za-z.\_\-\/]", "", sequence) # remove forbidden characters + sequence = regex_sub(r"\s+", " ", sequence) # no more than one consecutive whitespace allowed + # NOTE (optional) not recommended to use chars out of ([0123456789ABCDEFGHJKLMNPQRSTUVXYZ.\_\-\/ ]) + sequence += "TEST" if self.company_id.l10n_es_edi_test_env else "" + return sequence, number + + def _get_l10n_es_tbai_signature_and_date(self): + """ + Get the TicketBAI signature and registration date for this invoice. + Values are read directly from the 'post' XMLs submitted to the government \ + (the 'cancel' XML is ignored). + The registration date is the date the invoice was registered into the govt's TicketBAI servers. + """ + self.ensure_one() + vals = self._get_l10n_es_tbai_values_from_xml({ + 'signature': r'.//{http://www.w3.org/2000/09/xmldsig#}SignatureValue', + 'registration_date': r'.//CabeceraFactura//FechaExpedicionFactura' + }) + # RFC2045 - Base64 Content-Transfer-Encoding (page 25) + # Any characters outside of the base64 alphabet are to be ignored in base64-encoded data. + signature = vals['signature'].replace("\n", "") + registration_date = datetime.strptime(vals['registration_date'], '%d-%m-%Y') + return signature, registration_date + + def _get_l10n_es_tbai_id(self): + """Get the TicketBAI ID (TBAID) as defined in the TicketBAI doc.""" + self.ensure_one() + signature, registration_date = self._get_l10n_es_tbai_signature_and_date() + company = self.company_id + tbai_id_no_crc = '-'.join([ + 'TBAI', + str(company.vat[2:] if company.vat.startswith('ES') else company.vat), + datetime.strftime(registration_date, '%d%m%y'), + signature[:13], + '' # CRC + ]) + return tbai_id_no_crc + self._l10n_es_edi_tbai_crc8(tbai_id_no_crc) + + def _get_l10n_es_tbai_qr(self): + """Returns the URL for the invoice's QR code. We can not use url_encode because it escapes / e.g.""" + self.ensure_one() + if not self._l10n_es_tbai_is_in_chain(): + return '' + + company = self.company_id + sequence, number = self._get_l10n_es_tbai_sequence_and_number() + tbai_qr_no_crc = get_key(company.l10n_es_tbai_tax_agency, 'qr_url_', company.l10n_es_edi_test_env) + '?' + '&'.join([ + 'id=' + self._get_l10n_es_tbai_id(), + 's=' + sequence, + 'nf=' + number, + 'i=' + self._get_l10n_es_tbai_values_from_xml({'importe': r'.//ImporteTotalFactura'})['importe'] + ]) + qr_url = tbai_qr_no_crc + '&cr=' + self._l10n_es_edi_tbai_crc8(tbai_qr_no_crc) + return qr_url + + def _l10n_es_edi_tbai_crc8(self, data): + crc = 0x0 + for c in data: + crc = L10N_ES_TBAI_CRC8_TABLE[(crc ^ ord(c)) & 0xFF] + return '{:03d}'.format(crc & 0xFF) + + def _get_l10n_es_tbai_values_from_xml(self, xpaths): + """ + This function reads values directly from the 'post' XML submitted to the government \ + (the 'cancel' XML is ignored). + """ + res = dict.fromkeys(xpaths, '') + doc_xml = self._get_l10n_es_tbai_submitted_xml() + if doc_xml is None: + return res + for key, value in xpaths.items(): + res[key] = doc_xml.find(value).text + return res + + def _get_l10n_es_tbai_submitted_xml(self, cancel=False): + """Returns the XML object representing the post or cancel document.""" + self.ensure_one() + self = self.with_context(bin_size=False) + doc = self.l10n_es_tbai_cancel_xml if cancel else self.l10n_es_tbai_post_xml + if not doc: + return None + return etree.fromstring(b64decode(doc)) + + def _update_l10n_es_tbai_submitted_xml(self, xml_doc, cancel): + """Updates the binary data of the post or cancel document, from its XML object.""" + self.ensure_one() + b64_doc = b'' if xml_doc is None else b64encode(etree.tostring(xml_doc, encoding='UTF-8')) + if cancel: + self.l10n_es_tbai_cancel_xml = b64_doc + else: + self.l10n_es_tbai_post_xml = b64_doc + + def _is_l10n_es_tbai_simplified(self): + return self.commercial_partner_id == self.env.ref("l10n_es_edi_sii.partner_simplified") diff --git a/addons/l10n_es_edi_tbai/models/ir_attachment.py b/addons/l10n_es_edi_tbai/models/ir_attachment.py new file mode 100644 index 0000000000000000000000000000000000000000..4d88cfaff306b2235f8ffb141362e9a051cd5feb --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/ir_attachment.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, api +from odoo.addons.l10n_es_edi_tbai.models.l10n_es_edi_tbai_agencies import get_key +from odoo.tools import xml_utils + + +class IrAttachment(models.Model): + _inherit = 'ir.attachment' + + @api.model + def action_download_xsd_files(self): + """ + Downloads the TicketBAI XSD validation files if they don't already exist, for the active tax agency. + """ + xml_utils.load_xsd_files_from_url( + self.env, 'https://www.w3.org/TR/xmldsig-core/xmldsig-core-schema.xsd', 'xmldsig-core-schema.xsd', + xsd_name_prefix='l10n_es_edi_tbai') + + for agency in ['gipuzkoa', 'araba', 'bizkaia']: + urls = get_key(agency, 'xsd_url') + names = get_key(agency, 'xsd_name') + # For Bizkaia, one url per XSD (post/cancel) + if isinstance(urls, dict): + for move_type in ('post', 'cancel'): + xml_utils.load_xsd_files_from_url( + self.env, urls[move_type], names[move_type], + xsd_name_prefix='l10n_es_edi_tbai', + ) + # For other agencies, single url to zip file (only keep the desired names) + else: + xml_utils.load_xsd_files_from_url( + self.env, urls, # NOTE: file_name discarded when XSDs bundled in ZIPs + xsd_name_prefix='l10n_es_edi_tbai', + xsd_names_filter=list(names.values()), + ) + return super().action_download_xsd_files() diff --git a/addons/l10n_es_edi_tbai/models/l10n_es_edi_tbai_agencies.py b/addons/l10n_es_edi_tbai/models/l10n_es_edi_tbai_agencies.py new file mode 100644 index 0000000000000000000000000000000000000000..fe98ea40b1b48adfd19bc7d18c0abe95ebf8e59f --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/l10n_es_edi_tbai_agencies.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + + +# ===== TicketBAI TAX AGENCY METADATA ===== + +def get_key(agency, key, is_test_env=True): + """ + Helper method to retrieve specific data about certain agencies. + Notable differences in structure, by key + - Any key ending with '_': + These keys have two variants: 'test' and 'prod'. The parameter `is_test_env` matters for those keys only. + - 'xsd_url': + Araba and Gipuzkoa each have a single URL pointing to a zip file (which may contain many XSDs) + Bizkaia has two URLs for post/cancel XSDs: in that case a dict of strings is returned (instead of a single string) + """ + urls = { + 'araba': URLS_ARABA, + 'bizkaia': URLS_BIZKAIA, + 'gipuzkoa': URLS_GIPUZKOA, + }[agency] + if key.endswith('_'): + key += 'test' if is_test_env else 'prod' + return urls[key] + + +URLS_ARABA = { + 'sigpolicy_url': 'https://ticketbai.araba.eus/tbai/sinadura/', + 'sigpolicy_digest': '4Vk3uExj7tGn9DyUCPDsV9HRmK6KZfYdRiW3StOjcQA=', + 'xsd_url': 'https://web.araba.eus/documents/105044/5608600/TicketBai12+%282%29.zip', + 'xsd_name': { + 'post': 'ticketBaiV1-2.xsd', + 'cancel': 'Anula_ticketBaiV1-2.xsd', + }, + 'post_url_test': 'https://pruebas-ticketbai.araba.eus/TicketBAI/v1/facturas/', + 'post_url_prod': 'https://ticketbai.araba.eus/TicketBAI/v1/facturas/', + 'qr_url_test': 'https://pruebas-ticketbai.araba.eus/tbai/qrtbai/', + 'qr_url_prod': 'https://ticketbai.araba.eus/tbai/qrtbai/', + 'cancel_url_test': 'https://pruebas-ticketbai.araba.eus/TicketBAI/v1/anulaciones/', + 'cancel_url_prod': 'https://ticketbai.araba.eus/TicketBAI/v1/anulaciones/', +} + +URLS_BIZKAIA = { + 'sigpolicy_url': 'https://www.batuz.eus/fitxategiak/batuz/ticketbai/sinadura_elektronikoaren_zehaztapenak_especificaciones_de_la_firma_electronica_v1_0.pdf', + 'sigpolicy_digest': 'Quzn98x3PMbSHwbUzaj5f5KOpiH0u8bvmwbbbNkO9Es=', + 'xsd_url': { + 'post': 'https://www.batuz.eus/fitxategiak/batuz/ticketbai/ticketBaiV1-2-1.xsd', + 'cancel': 'https://www.batuz.eus/fitxategiak/batuz/ticketbai/Anula_ticketBaiV1-2-1.xsd', + }, + 'xsd_name': { + 'post': 'ticketBaiV1-2-1.xsd', + 'cancel': 'Anula_ticketBaiV1-2-1.xsd', + }, + 'post_url_test': 'https://pruesarrerak.bizkaia.eus/N3B4000M/aurkezpena', + 'post_url_prod': 'https://sarrerak.bizkaia.eus/N3B4000M/aurkezpena', + 'qr_url_test': 'https://batuz.eus/QRTBAI/', + 'qr_url_prod': 'https://batuz.eus/QRTBAI/', + 'cancel_url_test': 'https://pruesarrerak.bizkaia.eus/N3B4000M/aurkezpena', + 'cancel_url_prod': 'https://sarrerak.bizkaia.eus/N3B4000M/aurkezpena', +} + +URLS_GIPUZKOA = { + 'sigpolicy_url': 'https://www.gipuzkoa.eus/TicketBAI/signature', + 'sigpolicy_digest': '6NrKAm60o7u62FUQwzZew24ra2ve9PRQYwC21AM6In0=', + 'xsd_url': 'https://www.gipuzkoa.eus/documents/2456431/13761107/Esquemas+de+archivos+XSD+de+env%C3%ADo+y+anulaci%C3%B3n+de+factura_1_2.zip/2d116f8e-4d3a-bff0-7b03-df1cbb07ec52', + 'xsd_name': { + 'post': 'ticketBaiV1-2-1.xsd', + 'cancel': 'Anula_ticketBaiV1-2-1.xsd', + }, + 'post_url_test': 'https://tbai-prep.egoitza.gipuzkoa.eus/WAS/HACI/HTBRecepcionFacturasWEB/rest/recepcionFacturas/alta', + 'post_url_prod': 'https://tbai-z.egoitza.gipuzkoa.eus/sarrerak/alta', + 'qr_url_test': 'https://tbai.prep.gipuzkoa.eus/qr/', + 'qr_url_prod': 'https://tbai.egoitza.gipuzkoa.eus/qr/', + 'cancel_url_test': 'https://tbai-prep.egoitza.gipuzkoa.eus/WAS/HACI/HTBRecepcionFacturasWEB/rest/recepcionFacturas/anulacion', + 'cancel_url_prod': 'https://tbai-z.egoitza.gipuzkoa.eus/sarrerak/baja', +} diff --git a/addons/l10n_es_edi_tbai/models/l10n_es_edi_tbai_certificate.py b/addons/l10n_es_edi_tbai/models/l10n_es_edi_tbai_certificate.py new file mode 100644 index 0000000000000000000000000000000000000000..4d8e9d7cf440ee6b8ab0a6b5cf8544fd702e98ed --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/l10n_es_edi_tbai_certificate.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from base64 import b64decode + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.serialization import pkcs12 +from odoo import models + + +class Certificate(models.Model): + _inherit = 'l10n_es_edi.certificate' + + # ------------------------------------------------------------------------- + # HELPERS + # ------------------------------------------------------------------------- + + def _get_key_pair(self): + self.ensure_one() + + if not self.password: + return None, None + + private_key, certificate, dummy = pkcs12.load_key_and_certificates( + b64decode(self.with_context(bin_size=False).content), # Without bin_size=False, size is returned instead of content + self.password.encode(), + backend=default_backend(), + ) + + return private_key, certificate diff --git a/addons/l10n_es_edi_tbai/models/res_company.py b/addons/l10n_es_edi_tbai/models/res_company.py new file mode 100644 index 0000000000000000000000000000000000000000..306fc39ee51dc1dd71af8c674d91f1fd24348fca --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/res_company.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import markupsafe +from odoo import _, api, fields, models, release + + +# === TBAI license values === +L10N_ES_TBAI_LICENSE_DICT = { + 'production': { + 'license_name': _('Production license'), # all agencies + 'license_number': 'TBAIGI5A266A7CCDE1EC', + 'license_nif': 'N0251909H', + 'software_name': 'Odoo SA', + 'software_version': release.version, + }, + 'araba': { + 'license_name': _('Test license (Araba)'), + 'license_number': 'TBAIARbjjMClHKH00849', + 'license_nif': 'N0251909H', + 'software_name': 'Odoo SA', + 'software_version': release.version, + }, + 'bizkaia': { + 'license_name': _('Test license (Bizkaia)'), + 'license_number': 'TBAIBI00000000PRUEBA', + 'license_nif': 'A99800005', + 'software_name': 'SOFTWARE GARANTE TICKETBAI PRUEBA', + 'software_version': '1.0', + }, + 'gipuzkoa': { + 'license_name': _('Test license (Gipuzkoa)'), + 'license_number': 'TBAIGIPRE00000000965', + 'license_nif': 'N0251909H', + 'software_name': 'Odoo SA', + 'software_version': release.version, + }, +} + +class ResCompany(models.Model): + _inherit = 'res.company' + + # === TBAI config === + l10n_es_tbai_tax_agency = fields.Selection( + string="Tax Agency for TBAI", + selection=[ + ('araba', "Hacienda Foral de Araba"), # es-vi (region code) + ('bizkaia', "Hacienda Foral de Bizkaia"), # es-bi + ('gipuzkoa', "Hacienda Foral de Gipuzkoa"), # es-ss + ], + ) + l10n_es_tbai_license_html = fields.Html( + string="TicketBAI license", + compute='_compute_l10n_es_tbai_license_html', + ) + + # === TBAI CHAIN HEAD === + l10n_es_tbai_chain_sequence_id = fields.Many2one( + comodel_name='ir.sequence', + string='TicketBai account.move chain sequence', + readonly=True, + copy=False, + ) + + @api.depends('country_id', 'l10n_es_edi_test_env', 'l10n_es_tbai_tax_agency') + def _compute_l10n_es_tbai_license_html(self): + for company in self: + license_dict = company._get_l10n_es_tbai_license_dict() + if license_dict: + license_dict.update({ + 'tr_nif': _('Licence NIF'), + 'tr_number': _('Licence number'), + 'tr_name': _('Software name'), + 'tr_version': _('Software version') + }) + company.l10n_es_tbai_license_html = markupsafe.Markup(''' +<strong>{license_name}</strong><br/> +<p> +<strong>{tr_nif}: </strong>{license_nif}<br/> +<strong>{tr_number}: </strong>{license_number}<br/> +<strong>{tr_name}: </strong>{software_name}<br/> +<strong>{tr_version}: </strong>{software_version}<br/> +</p>''').format(**license_dict) + else: + company.l10n_es_tbai_license_html = markupsafe.Markup(''' +<strong>{tr_no_license}</strong>''').format(tr_no_license=_('TicketBAI is not configured')) + + def _get_l10n_es_tbai_license_dict(self): + self.ensure_one() + if self.country_code == 'ES' and self.l10n_es_tbai_tax_agency: + if self.l10n_es_edi_test_env: # test env: each agency has its test license + license_key = self.l10n_es_tbai_tax_agency + else: # production env: only one license + license_key = 'production' + return L10N_ES_TBAI_LICENSE_DICT[license_key] + else: + return {} + + def _get_l10n_es_tbai_next_chain_index(self): + if not self.l10n_es_tbai_chain_sequence_id: + self.l10n_es_tbai_chain_sequence_id = self.env['ir.sequence'].create({ + 'name': f'TicketBAI account move sequence for {self.name} (id: {self.id})', + 'code': f'l10n_es.edi.tbai.account.move.{self.id}', + 'implementation': 'no_gap', + 'company_id': self.id, + }) + return self.l10n_es_tbai_chain_sequence_id.next_by_id() + + def _get_l10n_es_tbai_last_posted_invoice(self, being_posted=False): + """ + Returns the last invoice posted to this company's chain. + That invoice may have been received by the govt or not (eg. in case of a timeout). + Only upon confirmed reception/refusal of that invoice can another one be posted. + :param being_posted: next invoice to be posted on the chain, ignored in search domain + """ + domain = [ + ('l10n_es_tbai_chain_index', '!=', 0), + ('company_id', '=', self.id) + ] + if being_posted: + domain.append(('l10n_es_tbai_chain_index', '!=', being_posted.l10n_es_tbai_chain_index)) + # NOTE: being_posted may not have a chain index at all (if being posted for the first time) + return self.env['account.move'].search(domain, limit=1, order='l10n_es_tbai_chain_index desc') diff --git a/addons/l10n_es_edi_tbai/models/res_config_settings.py b/addons/l10n_es_edi_tbai/models/res_config_settings.py new file mode 100644 index 0000000000000000000000000000000000000000..66fe05e33f0757e826f9d410c2e4203fd7cd576b --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/res_config_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + l10n_es_tbai_tax_agency = fields.Selection(related='company_id.l10n_es_tbai_tax_agency', readonly=False) diff --git a/addons/l10n_es_edi_tbai/models/xml_utils.py b/addons/l10n_es_edi_tbai/models/xml_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..39507e1937d9ff01b11b18aff56119f761b176e9 --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/xml_utils.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import hashlib +import re +from base64 import b64encode, encodebytes + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding +from lxml import etree +from odoo.tools.xml_utils import cleanup_xml_node + + +# Utility Methods for Basque Country's TicketBAI XML-related stuff. + +NS_MAP = {'': 'http://www.w3.org/2000/09/xmldsig#'} # default namespace matches signature's `ds:`` + +def canonicalize_node(node): + """ + Returns the canonical (C14N 1.0, without comments, non exclusive) representation of node. + Speficied in: https://www.w3.org/TR/2001/REC-xml-c14n-20010315 + Required for computing digests and signatures. + Returns an UTF-8 encoded bytes string. + """ + node = etree.fromstring(node) if isinstance(node, str) else node + return etree.tostring(node, method='c14n', with_comments=False, exclusive=False) + +def cleanup_xml_signature(xml_sig): + """ + Cleanups the content of the provided string representation of an XML signature. + In addition, removes all line feeds for the ds:Object element. + Turns self-closing tags into regular tags (with an empty string content) + as the former may not be supported by some signature validation implementations. + Returns an etree._Element + """ + sig_elem = cleanup_xml_node(xml_sig, remove_blank_nodes=False, indent_level=-1) + etree.indent(sig_elem, space='') # removes indentation + for elem in sig_elem.find('Object', namespaces=NS_MAP).iter(): + if elem.text == '\n': + elem.text = '' # keeps the signature in one line, prevents self-closing tags + elem.tail = '' # removes line feed and whitespace after the tag + return sig_elem + +def get_uri(uri, reference, base_uri): + """ + Returns the content within `reference` that is identified by `uri`. + Canonicalization is used to convert node reference to an octet stream. + - The base_uri points to the whole document tree, without the signature + https://www.w3.org/TR/xmldsig-core/#sec-EnvelopedSignature + + - URIs starting with # are same-document references + https://www.w3.org/TR/xmldsig-core/#sec-URI + + Returns an UTF-8 encoded bytes string. + """ + node = reference.getroottree() + if uri == base_uri: + # Empty URI: whole document, without signature + return canonicalize_node( + re.sub( + r'^[^\n]*<ds:Signature.*<\/ds:Signature>', r'', + etree.tostring(node, encoding='unicode'), + flags=re.DOTALL | re.MULTILINE) + ) + + if uri.startswith('#'): + query = '//*[@*[local-name() = "Id" ]=$uri]' # case-sensitive 'Id' + results = node.xpath(query, uri=uri.lstrip('#')) + if len(results) == 1: + return canonicalize_node(results[0]) + if len(results) > 1: + raise Exception("Ambiguous reference URI {} resolved to {} nodes".format( + uri, len(results))) + + raise Exception(f"URI {uri!r} not found") + +def calculate_references_digests(node, base_uri=''): + """ + Processes the references from node and computes their digest values as specified in + https://www.w3.org/TR/xmldsig-core/#sec-DigestMethod + https://www.w3.org/TR/xmldsig-core/#sec-DigestValue + """ + for reference in node.findall('Reference', namespaces=NS_MAP): + ref_node = get_uri(reference.get('URI', ''), reference, base_uri) + hash_digest = hashlib.new('sha256', ref_node).digest() + reference.find('DigestValue', namespaces=NS_MAP).text = b64encode(hash_digest) + +def fill_signature(node, private_key): + """ + Uses private_key to sign the SignedInfo sub-node of `node`, as specified in: + https://www.w3.org/TR/xmldsig-core/#sec-SignatureValue + https://www.w3.org/TR/xmldsig-core/#sec-SignedInfo + """ + signed_info_xml = node.find('SignedInfo', namespaces=NS_MAP) + + # During signature generation, the digest is computed over the canonical form of the document + signature = private_key.sign( + canonicalize_node(signed_info_xml), + padding.PKCS1v15(), + hashes.SHA256() + ) + node.find('SignatureValue', namespaces=NS_MAP).text =\ + bytes_as_block(signature) + +def int_as_bytes(number): + """ + Converts an integer to an ASCII/UTF-8 byte string (with no leading zeroes). + """ + return number.to_bytes((number.bit_length() + 7) // 8, byteorder='big') + +def bytes_as_block(string): + """ + Returns the passed string modified to include a line feed every `length` characters. + It may be recommended to keep length under 76: + https://www.w3.org/TR/2004/REC-xmlschema-2-20041028/#rf-maxLength + https://www.ietf.org/rfc/rfc2045.txt + """ + return encodebytes(string).decode() diff --git a/addons/l10n_es_edi_tbai/tests/__init__.py b/addons/l10n_es_edi_tbai/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ab3391c443ab52231c622369ffa562ec5213e9a6 --- /dev/null +++ b/addons/l10n_es_edi_tbai/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_edi_web_services +from . import test_edi_xml diff --git a/addons/l10n_es_edi_tbai/tests/common.py b/addons/l10n_es_edi_tbai/tests/common.py new file mode 100644 index 0000000000000000000000000000000000000000..4e5d2cdf051500cf0e059a02d18d9d406fc8ff62 --- /dev/null +++ b/addons/l10n_es_edi_tbai/tests/common.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 +from datetime import datetime + +from odoo.addons.account_edi.tests.common import AccountEdiTestCommon +from odoo.tests import tagged +from odoo.tools import misc +from pytz import timezone + + +@tagged('post_install_l10n', 'post_install', '-at_install') +class TestEsEdiTbaiCommon(AccountEdiTestCommon): + + @classmethod + def setUpClass(cls, chart_template_ref='l10n_es.account_chart_template_full', edi_format_ref='l10n_es_edi_tbai.edi_es_tbai'): + super().setUpClass(chart_template_ref=chart_template_ref, edi_format_ref=edi_format_ref) + + cls.frozen_today = datetime(year=2022, month=1, day=1, hour=0, minute=0, second=0, tzinfo=timezone('utc')) + + # Allow to see the full result of AssertionError. + cls.maxDiff = None + + # ==== Config ==== + + cls.company_data['company'].write({ + 'name': 'EUS Company', + 'country_id': cls.env.ref('base.es').id, + 'state_id': cls.env.ref('base.state_es_ss').id, + 'vat': 'ES09760433S', + 'l10n_es_edi_test_env': True, + }) + + cls.certificate = None + cls._set_tax_agency('gipuzkoa') + + # ==== Business ==== + + cls.partner_a.write({ + 'name': "&@à Ã$£€èêÈÊöÔÇç¡⅛™³", # special characters should be escaped appropriately + 'vat': 'BE0477472701', + 'country_id': cls.env.ref('base.be').id, + 'street': 'Rue Sans Souci 1', + 'zip': 93071, + }) + + cls.partner_b.write({ + 'vat': 'ESF35999705', + }) + + cls.product_t = cls.env["product.product"].create( + {"name": "Test product"}) + cls.partner_t = cls.env["res.partner"].create({"name": "Test partner", "vat": "ESF35999705"}) + + @classmethod + def _set_tax_agency(cls, agency): + if agency == "araba": + cert_name = 'araba_1234.p12' + cert_password = '1234' + elif agency == 'bizkaia': + cert_name = 'bizkaia_111111.p12' + cert_password = '111111' + elif agency == 'gipuzkoa': + cert_name = 'gipuzkoa_IZDesa2021.p12' + cert_password = 'IZDesa2021' + else: + raise ValueError("Unknown tax agency: " + agency) + + cls.certificate = cls.env['l10n_es_edi.certificate'].create({ + 'content': base64.encodebytes( + misc.file_open("l10n_es_edi_tbai/demo/certificates/" + cert_name, 'rb').read()), + 'password': cert_password, + }) + cls.company_data['company'].write({ + 'l10n_es_tbai_tax_agency': agency, + 'l10n_es_edi_certificate_id': cls.certificate.id, + }) + + @classmethod + def _get_tax_by_xml_id(cls, trailing_xml_id): + """ Helper to retrieve a tax easily. + + :param trailing_xml_id: The trailing tax's xml id. + :return: An account.tax record + """ + return cls.env.ref(f'l10n_es.{cls.env.company.id}_account_tax_template_{trailing_xml_id}') + + @classmethod + def create_invoice(cls, **kwargs): + return cls.env['account.move'].with_context(edi_test_mode=True).create({ + 'move_type': 'out_invoice', + 'partner_id': cls.partner_a.id, + 'invoice_date': '2022-01-01', + 'date': '2022-01-01', + **kwargs, + 'invoice_line_ids': [(0, 0, { + 'product_id': cls.product_a.id, + 'price_unit': 1000.0, + **line_vals, + }) for line_vals in kwargs.get('invoice_line_ids', [])], + }) + + L10N_ES_TBAI_SAMPLE_XML_POST = """<?xml version='1.0' encoding='UTF-8'?> +<T:TicketBai xmlns:etsi="http://uri.etsi.org/01903/v1.3.2#" xmlns:T="urn:ticketbai:emision" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"> + <Cabecera> + <IDVersionTBAI>1.2</IDVersionTBAI> + </Cabecera> + <Sujetos> + <Emisor> + <NIF>___ignore___</NIF> + <ApellidosNombreRazonSocial>EUS Company</ApellidosNombreRazonSocial> + </Emisor> + <Destinatarios> + <IDDestinatario> + <IDOtro> + <IDType>02</IDType> + <ID>BE0477472701</ID> + </IDOtro> + <ApellidosNombreRazonSocial>&@àÁ$£€èêÈÊöÔÇç¡⅛™³</ApellidosNombreRazonSocial> + <CodigoPostal>___ignore___</CodigoPostal> + <Direccion>___ignore___</Direccion> + </IDDestinatario> + </Destinatarios> + <VariosDestinatarios>N</VariosDestinatarios> + <EmitidaPorTercerosODestinatario>N</EmitidaPorTercerosODestinatario> + </Sujetos> + <Factura> + <CabeceraFactura> + <SerieFactura>INVTEST</SerieFactura> + <NumFactura>01</NumFactura> + <FechaExpedicionFactura>01-01-2022</FechaExpedicionFactura> + <HoraExpedicionFactura>___ignore___</HoraExpedicionFactura> + <FacturaSimplificada>N</FacturaSimplificada> + </CabeceraFactura> + <DatosFactura> + <DescripcionFactura>manual</DescripcionFactura> + <DetallesFactura> + <IDDetalleFactura> + <DescripcionDetalle>producta</DescripcionDetalle> + <Cantidad>5.00</Cantidad> + <ImporteUnitario>1000.00</ImporteUnitario> + <Descuento>1000.00</Descuento> + <ImporteTotal>4840.00</ImporteTotal> + </IDDetalleFactura> + </DetallesFactura> + <ImporteTotalFactura>4840.00</ImporteTotalFactura> + <Claves> + <IDClave> + <ClaveRegimenIvaOpTrascendencia>01</ClaveRegimenIvaOpTrascendencia> + </IDClave> + </Claves> + </DatosFactura> + <TipoDesglose> + <DesgloseTipoOperacion> + <Entrega> + <Sujeta> + <NoExenta> + <DetalleNoExenta> + <TipoNoExenta>S1</TipoNoExenta> + <DesgloseIVA> + <DetalleIVA> + <BaseImponible>4000.00</BaseImponible> + <TipoImpositivo>21.00</TipoImpositivo> + <CuotaImpuesto>840.00</CuotaImpuesto> + <OperacionEnRecargoDeEquivalenciaORegimenSimplificado>N</OperacionEnRecargoDeEquivalenciaORegimenSimplificado> + </DetalleIVA> + </DesgloseIVA> + </DetalleNoExenta> + </NoExenta> + </Sujeta> + </Entrega> + </DesgloseTipoOperacion> + </TipoDesglose> + </Factura> + <HuellaTBAI> + <Software> + <LicenciaTBAI>___ignore___</LicenciaTBAI> + <EntidadDesarrolladora> + <NIF>___ignore___</NIF> + </EntidadDesarrolladora> + <Nombre>___ignore___</Nombre> + <Version>___ignore___</Version> + </Software> + <NumSerieDispositivo>___ignore___</NumSerieDispositivo> + </HuellaTBAI> +</T:TicketBai> +""".encode("utf-8") + + L10N_ES_TBAI_SAMPLE_XML_CANCEL = """<T:AnulaTicketBai xmlns:T="urn:ticketbai:anulacion"> + <Cabecera> + <IDVersionTBAI>1.2</IDVersionTBAI> + </Cabecera> + <IDFactura> + <Emisor> + <NIF>09760433S</NIF> + <ApellidosNombreRazonSocial>EUS Company</ApellidosNombreRazonSocial> + </Emisor> + <CabeceraFactura> + <SerieFactura>INVTEST</SerieFactura> + <NumFactura>01</NumFactura> + <FechaExpedicionFactura>01-01-2022</FechaExpedicionFactura> + </CabeceraFactura> + </IDFactura> + <HuellaTBAI> + <Software> + <LicenciaTBAI>___ignore___</LicenciaTBAI> + <EntidadDesarrolladora> + <NIF>___ignore___</NIF> + </EntidadDesarrolladora> + <Nombre>___ignore___</Nombre> + <Version>___ignore___</Version> + </Software> + <NumSerieDispositivo>___ignore___</NumSerieDispositivo> + </HuellaTBAI> +</T:AnulaTicketBai>""".encode("utf-8") diff --git a/addons/l10n_es_edi_tbai/tests/test_edi_web_services.py b/addons/l10n_es_edi_tbai/tests/test_edi_web_services.py new file mode 100644 index 0000000000000000000000000000000000000000..be5da915b8b244c10437a460b8e598aa9e2c9528 --- /dev/null +++ b/addons/l10n_es_edi_tbai/tests/test_edi_web_services.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime + +from odoo import fields +from odoo.tests import tagged + +from .common import TestEsEdiTbaiCommon + + +@tagged('external_l10n', '-standard', 'external') +class TestEdiTbaiWebServices(TestEsEdiTbaiCommon): + + @classmethod + def setUpClass(cls, chart_template_ref='l10n_es.account_chart_template_full', edi_format_ref='l10n_es_edi_tbai.edi_es_tbai'): + super().setUpClass(chart_template_ref=chart_template_ref, edi_format_ref=edi_format_ref) + + # Invoice name are tracked by the web-services so this constant tries to get a new unique invoice name at each + # execution. + cls.today = datetime.now() + cls.time_name = cls.today.strftime('%H%M%S') + + cls.out_invoice = cls.env['account.move'].create({ + 'name': f'INV/{cls.time_name}', + 'move_type': 'out_invoice', + 'partner_id': cls.partner_a.id, + 'invoice_line_ids': [(0, 0, { + 'product_id': cls.product_a.id, + 'price_unit': 1000.0, + 'quantity': 5, + 'discount': 20.0, + 'tax_ids': [(6, 0, cls._get_tax_by_xml_id('s_iva21b').ids)], + })], + }) + cls.out_invoice.action_post() + + cls.in_invoice = cls.env['account.move'].create({ + 'name': f'BILL{cls.time_name}', + 'ref': f'REFBILL{cls.time_name}', + 'move_type': 'in_invoice', + 'partner_id': cls.partner_a.id, + 'invoice_date': fields.Date.to_string(cls.today.date()), + 'invoice_line_ids': [(0, 0, { + 'product_id': cls.product_a.id, + 'price_unit': 1000.0, + 'quantity': 5, + 'discount': 20.0, + 'tax_ids': [(6, 0, cls._get_tax_by_xml_id('p_iva10_bc').ids)], + })], + }) + cls.in_invoice.action_post() + + cls.moves = cls.out_invoice + cls.in_invoice + + def test_edi_gipuzkoa(self): + self._set_tax_agency('gipuzkoa') + self.moves.action_process_edi_web_services(with_commit=False) + generated_files = self._process_documents_web_services(self.moves, {'es_tbai'}) + self.assertTrue(generated_files) + self.assertRecordValues(self.out_invoice, [{'edi_state': 'sent'}]) diff --git a/addons/l10n_es_edi_tbai/tests/test_edi_xml.py b/addons/l10n_es_edi_tbai/tests/test_edi_xml.py new file mode 100644 index 0000000000000000000000000000000000000000..334f9ed6c4e93909ef36e467490a05b655d3ce76 --- /dev/null +++ b/addons/l10n_es_edi_tbai/tests/test_edi_xml.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from base64 import b64encode +from datetime import datetime + +from freezegun import freeze_time +from lxml import etree + +from odoo.addons.l10n_es_edi_tbai.models.xml_utils import NS_MAP +from odoo.tests import tagged + +from .common import TestEsEdiTbaiCommon + + +@tagged('post_install', '-at_install', 'post_install_l10n') +class TestEdiTbaiXmls(TestEsEdiTbaiCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.out_invoice = cls.env['account.move'].create({ + 'name': 'INV/01', + 'move_type': 'out_invoice', + 'invoice_date': datetime.now(), + 'partner_id': cls.partner_a.id, + 'invoice_line_ids': [(0, 0, { + 'product_id': cls.product_a.id, + 'price_unit': 1000.0, + 'quantity': 5, + 'discount': 20.0, + 'tax_ids': [(6, 0, cls._get_tax_by_xml_id('s_iva21b').ids)], + })], + }) + + cls.edi_format = cls.env.ref('l10n_es_edi_tbai.edi_es_tbai') + + def test_xml_tree_post(self): + with freeze_time(self.frozen_today): + xml_doc = self.edi_format._get_l10n_es_tbai_invoice_xml(self.out_invoice, cancel=False)[self.out_invoice]['xml_file'] + xml_doc.remove(xml_doc.find("Signature", namespaces=NS_MAP)) + xml_expected = etree.fromstring(super().L10N_ES_TBAI_SAMPLE_XML_POST) + self.assertXmlTreeEqual(xml_doc, xml_expected) + + def test_xml_tree_cancel(self): + self.out_invoice.l10n_es_tbai_post_xml = b64encode(b"""<TicketBAI> +<CabeceraFactura><FechaExpedicionFactura>01-01-2022</FechaExpedicionFactura></CabeceraFactura> +<ds:SignatureValue xmlns:ds="http://www.w3.org/2000/09/xmldsig#">TEXT</ds:SignatureValue> +</TicketBAI>""") # hack to set out_invoice's registration date + xml_doc = self.edi_format._get_l10n_es_tbai_invoice_xml(self.out_invoice, cancel=True)[self.out_invoice]['xml_file'] + xml_doc.remove(xml_doc.find("Signature", namespaces=NS_MAP)) + xml_expected = etree.fromstring(super().L10N_ES_TBAI_SAMPLE_XML_CANCEL) + self.assertXmlTreeEqual(xml_doc, xml_expected) diff --git a/addons/l10n_es_edi_tbai/views/account_move_view.xml b/addons/l10n_es_edi_tbai/views/account_move_view.xml new file mode 100644 index 0000000000000000000000000000000000000000..f7c6320f4c3e22197f75821248b428c5e4f32c7a --- /dev/null +++ b/addons/l10n_es_edi_tbai/views/account_move_view.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + <record id="view_move_form_inherit_l10n_es_edi_tbai" model="ir.ui.view"> + <field name="name">account.move.form.inherit.l10n_es_edi_tbai</field> + <field name="model">account.move</field> + <field name="inherit_id" ref="account.view_move_form"/> + <field name="arch" type="xml"> + <xpath expr="//group[@id='other_tab_group']/group[last()]" position='after'> + <group id="ticketbai_group" string="TicketBAI" attrs="{'invisible': [('l10n_es_tbai_is_required', '=', False)]}"> + <field name="l10n_es_tbai_is_required" invisible="1"/> + <field name="l10n_es_tbai_chain_index" groups="base.group_no_one"/> + <field name="l10n_es_tbai_refund_reason" + attrs="{ + 'readonly': [('state', '!=', 'draft')], + 'invisible': [('l10n_es_tbai_refund_reason', '=', False)] + }"/> + </group> + </xpath> + </field> + </record> + </data> +</odoo> diff --git a/addons/l10n_es_edi_tbai/views/report_invoice.xml b/addons/l10n_es_edi_tbai/views/report_invoice.xml new file mode 100644 index 0000000000000000000000000000000000000000..53381b1b5eae8e446a6bffbaef7f62b3b3eec26e --- /dev/null +++ b/addons/l10n_es_edi_tbai/views/report_invoice.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<odoo> + <template id="l10n_es_tbai_external_layout_standard" inherit_id="account.report_invoice_document"> + <xpath expr="//div[@id='qrcode']" position="after"> + <div name="l10n_es_tbai_qrcode" t-if="o.l10n_es_tbai_is_required" style="page-break-inside: avoid"> + <div t-out="o._get_l10n_es_tbai_id()"/> + <img + t-att-src="'/report/barcode/?barcode_type=%s&value=%s&width=%s&height=%s&barLevel=%s'%('QR', quote_plus(o._get_l10n_es_tbai_qr()), 125, 125, 'M')"/> + + <!-- NOTE: Sizes assume a 90 dpi resolution to meet requirements (between 30 and 40 mm) --> + </div> + </xpath> + </template> +</odoo> diff --git a/addons/l10n_es_edi_tbai/views/res_company_views.xml b/addons/l10n_es_edi_tbai/views/res_company_views.xml new file mode 100644 index 0000000000000000000000000000000000000000..31b2236f829222aba67979289d2c1b232e7d8bdc --- /dev/null +++ b/addons/l10n_es_edi_tbai/views/res_company_views.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<odoo> + <record id="res_company_form_l10n_es_edi_tbai" model="ir.ui.view"> + <field name="name">res.company.form</field> + <field name="model">res.company</field> + <field name="inherit_id" ref="account.view_company_form"/> + <field name="arch" type="xml"> + <xpath expr="//page[@name='general_info']/group/group[last()]" position="after"> + <group> + <field name="l10n_es_tbai_license_html" type="object" + attrs="{'invisible': [('country_code', '!=', 'ES')]}"/> + </group> + </xpath> + </field> + </record> + + <!-- TicketBAI specifies there needs to be a menu link to display the license information --> + <menuitem id="menu_l10n_es_edi_tbai_license" + name="Licenses (TicketBAI)" + action="base.action_res_company_form" + sequence="90" + parent="l10n_es_edi_sii.menu_l10n_es_edi_root" + groups="account.group_account_manager"> + </menuitem> +</odoo> diff --git a/addons/l10n_es_edi_tbai/views/res_config_settings_views.xml b/addons/l10n_es_edi_tbai/views/res_config_settings_views.xml new file mode 100644 index 0000000000000000000000000000000000000000..c9bb4d8282dbd396e714c9ba4b2bcd85de69750c --- /dev/null +++ b/addons/l10n_es_edi_tbai/views/res_config_settings_views.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="res_config_settings_view_form" model="ir.ui.view"> + <field name="name">res.config.settings.view.form.inherit.l10n.es</field> + <field name="model">res.config.settings</field> + <field name="inherit_id" ref="account.res_config_settings_view_form"/> + <field name="arch" type="xml"> + <xpath expr="//div[@data-key='account']/div[@name='spain_localization']//div[hasclass('o_setting_right_pane')]/span[hasclass('o_form_label')]" position="replace"> + <span class="o_form_label">Registro de Libros connection SII/TicketBAI</span> + </xpath> + <xpath expr="//div[@data-key='account']/div[@name='spain_localization']//div[hasclass('o_setting_right_pane')]//div[hasclass('mt16')]/*" position="before"> + <label for="l10n_es_tbai_tax_agency" class="o_light_label"/> + <field name="l10n_es_tbai_tax_agency"/> + <div class="text-muted" attrs="{'invisible': [('l10n_es_tbai_tax_agency', '!=', False)]}"> + No tax agency selected: TicketBAI not activated. + </div> + <div class="text-muted" attrs="{'invisible': [('l10n_es_tbai_tax_agency', '=', False)]}"> + Tax agency selected: TicketBAI is activated. + </div> + <br/> + </xpath> + </field> + </record> +</odoo> diff --git a/addons/l10n_es_edi_tbai/wizards/__init__.py b/addons/l10n_es_edi_tbai/wizards/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..46cf10a81f5c52b381e6370dc497e48ffb49b664 --- /dev/null +++ b/addons/l10n_es_edi_tbai/wizards/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_move_reversal diff --git a/addons/l10n_es_edi_tbai/wizards/account_move_reversal.py b/addons/l10n_es_edi_tbai/wizards/account_move_reversal.py new file mode 100644 index 0000000000000000000000000000000000000000..52eab04e37cc3fc9f48bfaa1cfad27d0606246dd --- /dev/null +++ b/addons/l10n_es_edi_tbai/wizards/account_move_reversal.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields, models, api +from odoo.exceptions import UserError + + +class AccountMoveReversal(models.TransientModel): + _inherit = 'account.move.reversal' + + l10n_es_tbai_is_required = fields.Boolean( + compute="_compute_l10n_es_tbai_is_required", readonly=True, + string="Is TicketBai required for this reversal", + ) + + l10n_es_tbai_refund_reason = fields.Selection( + selection=[ + ('R1', "R1: Art. 80.1, 80.2, 80.6 and rights founded error"), + ('R2', "R2: Art. 80.3"), + ('R3', "R3: Art. 80.4"), + ('R4', "R4: Art. 80 - other"), + ('R5', "R5: Factura rectificativa en facturas simplificadas"), + ], + string="Invoice Refund Reason Code (TicketBai)", + help="BOE-A-1992-28740. Ley 37/1992, de 28 de diciembre, del Impuesto sobre el " + "Valor Añadido. ArtÃculo 80. Modificación de la base imponible.", + ) + + @api.depends('move_ids') + def _compute_l10n_es_tbai_is_required(self): + for wizard in self: + moves_tbai_required = set(m.l10n_es_tbai_is_required for m in wizard.move_ids) + if len(moves_tbai_required) > 1: + raise UserError("Reversals mixing invoices with and without TicketBAI are not allowed.") + wizard.l10n_es_tbai_is_required = moves_tbai_required.pop() + + def _prepare_default_reversal(self, move): + # OVERRIDE + values = super()._prepare_default_reversal(move) + if move.company_id.country_id.code == "ES" and move.l10n_es_tbai_is_required: + values.update({ + 'l10n_es_tbai_refund_reason': self.l10n_es_tbai_refund_reason, + }) + return values diff --git a/addons/l10n_es_edi_tbai/wizards/account_move_reversal_views.xml b/addons/l10n_es_edi_tbai/wizards/account_move_reversal_views.xml new file mode 100644 index 0000000000000000000000000000000000000000..8ff2043a86f68d59967dd1909f91031ffc1bea10 --- /dev/null +++ b/addons/l10n_es_edi_tbai/wizards/account_move_reversal_views.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="view_account_move_reversal" model="ir.ui.view"> + <field name="name">account.move.reversal.form.inherit.l10n_es_edi_tbai</field> + <field name="model">account.move.reversal</field> + <field name="inherit_id" ref="account.view_account_move_reversal"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='journal_id']" position="after"> + <field name="l10n_es_tbai_is_required" invisible="1"/> + <field attrs="{'invisible': [('l10n_es_tbai_is_required', '=', False)]}" name="l10n_es_tbai_refund_reason" widget="selection"/> + </xpath> + </field> + </record> +</odoo>