diff --git a/l10n_es_aeat_verifactu/README.rst b/l10n_es_aeat_verifactu/README.rst index 79685bcd841..f561f953ce5 100644 --- a/l10n_es_aeat_verifactu/README.rst +++ b/l10n_es_aeat_verifactu/README.rst @@ -35,14 +35,74 @@ Módulo para la presentación inmediata de la facturación. .. contents:: :local: +Installation +============ + +Para instalar esté módulo necesita: + +#. Libreria Python Zeep, se puede instalar con el comando 'pip install zeep' +#. Libreria Python Requests, se puede instalar con el comando 'pip install requests' + +y el módulo `queue_job` que se encuentra en: + +https://github.com/OCA/queue + +Configuration +============= + +Para configurar este módulo es necesario: + +#. En la compañia se almacenan las URLs del servicio SOAP de hacienda. + Estas URLs pueden cambiar según comunidades +#. Los certificados deben alojarse en una carpeta accesible por la instalación + de Odoo. +#. Preparar el certificado. El certificado enviado por la FMNT es en formato + p12, este certificado no se puede usar directamente con Zeep. Se tiene que + extraer la clave pública y la clave privada. + +En Linux se pueden usar los siguientes comandos: + +- Clave pública: "openssl pkcs12 -in Certificado.p12 -nokeys -out publicCert.crt -nodes" +- Clave privada: "openssl pkcs12 -in Certifcado.p12 -nocerts -out privateKey.pem -nodes" + +Además, el módulo `queue_job` necesita estar configurado de una de estas formas: + +#. Ajustando variables de entorno: + + ODOO_QUEUE_JOB_CHANNELS=root:4 + + u otro canal de configuración. Por defecto es root:1 + + Si xmlrpc_port no está definido: ODOO_QUEUE_JOB_PORT=8069 + +#. Otra alternativa es usuando un fichero de configuración: + + [options] + (...) + workers = 4 + server_wide_modules = web,base_sparse_field,queue_job + + (...) + [queue_job] + channels = root:4 + +#. Por último, arrancando Odoo con --load=web,base_sparse_field,queue_job y --workers más grande que 1. + +Más información http://odoo-connector.com + +#. Establecer en las posiciones fiscales la clave de impuestos y la clave de registro verifactu. + Known issues / Roadmap ====================== - * Refactorización SII en l10n_es_aeat - * Creación documento a enviar a Veri*FACTU - * Creación cabecera Veri*FACTU - * Conexión WSDL - * Queue + Encadenamiento + * Refactorización SII-Verifactu en l10n_es_aeat cuando estén todos los procesos claros + * Envío de Facturas simplificadas, exentas, a terceros.. + * Encadenamiento, obtener factura anterior y almacenamiento del hash inalterable. + * Datas de mapeos de impuestos, ya hay algunos. + * Datos reales del desarrollador del sistema informático. + * Envío con Queue. + * Modificación de facturas enviadas. + * Anulación de facturas enviadas. Bug Tracker =========== @@ -67,6 +127,7 @@ Contributors ~~~~~~~~~~~~ * Jose Zambudio +* Almudena de La Puente * Laura Cazorla * Andreu Orensanz diff --git a/l10n_es_aeat_verifactu/__init__.py b/l10n_es_aeat_verifactu/__init__.py index 0650744f6bc..aee8895e7a3 100644 --- a/l10n_es_aeat_verifactu/__init__.py +++ b/l10n_es_aeat_verifactu/__init__.py @@ -1 +1,2 @@ from . import models +from . import wizards diff --git a/l10n_es_aeat_verifactu/__manifest__.py b/l10n_es_aeat_verifactu/__manifest__.py index 53a000f3fed..fbabed4eb9f 100644 --- a/l10n_es_aeat_verifactu/__manifest__.py +++ b/l10n_es_aeat_verifactu/__manifest__.py @@ -11,19 +11,26 @@ "license": "AGPL-3", "application": False, "installable": True, - # "external_dependencies": {"python": ["zeep", "requests"]}, + "external_dependencies": {"python": ["zeep", "requests"]}, "depends": [ "l10n_es", "l10n_es_aeat", - # "queue_job", + "account_invoice_refund_link", + "queue_job", ], "data": [ - "data/aeat_sii_tax_agency_data.xml", + "data/aeat_verifactu_tax_agency_data.xml", + "data/aeat_verifactu_registration_keys.xml", + "data/aeat_verifactu_map_data.xml", + "security/ir.model.access.csv", "views/aeat_tax_agency_view.xml", "views/account_move_view.xml", "views/account_fiscal_position_view.xml", "views/res_company_view.xml", "views/res_partner_view.xml", - "views/account_journal_views.xml", + "views/account_journal_view.xml", + "views/aeat_verifactu_map_view.xml", + "views/aeat_verifactu_map_lines_view.xml", + "views/aeat_verifactu_registration_keys_view.xml", ], } diff --git a/l10n_es_aeat_verifactu/data/aeat_verifactu_map_data.xml b/l10n_es_aeat_verifactu/data/aeat_verifactu_map_data.xml new file mode 100644 index 00000000000..812dffb1eca --- /dev/null +++ b/l10n_es_aeat_verifactu/data/aeat_verifactu_map_data.xml @@ -0,0 +1,79 @@ + + + + + Verifactu + + + S1 + + + Operación Sujeta y No exenta - Sin inversión del sujeto pasivo. + + + S2 + + + Operación Sujeta y No exenta - Con Inversión del sujeto pasivo + + + N1 + + + Operación No Sujeta artículo 7, 14, otros. + + + + N2 + + + Operación No Sujeta por Reglas de localización. + + + + RE + + + Recargo Equivalencia + + + diff --git a/l10n_es_aeat_verifactu/data/aeat_verifactu_registration_keys.xml b/l10n_es_aeat_verifactu/data/aeat_verifactu_registration_keys.xml new file mode 100644 index 00000000000..72ffff457bd --- /dev/null +++ b/l10n_es_aeat_verifactu/data/aeat_verifactu_registration_keys.xml @@ -0,0 +1,306 @@ + + + + + 01 + Operación de régimen general + 01 + + + 02 + Exportación + 01 + + + 03 + Operaciones a las que se aplique el régimen especial de bienes usados, objetos de arte, antigüedades y objetos de colección + 01 + + + 04 + Régimen especial oro de inversión + 01 + + + 05 + Régimen especial agencias de viajes + 01 + + + 06 + Régimen especial grupo de entidades en IVA (Nivel Avanzado) + 01 + + + 07 + Régimen especial criterio de caja + 01 + + + 08 + Operaciones sujetas al IPSI / IGIC (Impuesto sobre la Producción, los Servicios y la Importación / Impuesto General Indirecto Canario) + 01 + + + 09 + Facturación de las prestaciones de servicios de agencias de viaje que actúan como mediadoras en nombre y por cuenta ajena (D.A.4ª RD1619/2012) + 01 + + + 10 + Cobros por cuenta de terceros de honorarios profesionales o de derechos derivados de la propiedad industrial, de autor u otros por cuenta de sus socios, asociados o colegiados efectuados por sociedades, asociaciones, colegios profesionales u otras entidades que realicen estas funciones de cobro + 01 + + + 11 + Operaciones de arrendamiento de local de negocio. + 01 + + + 14 + Factura con IVA pendiente de devengo en certificaciones de obra cuyo destinatario sea una Administración Pública. + 01 + + + 15 + Factura con IVA pendiente de devengo en operaciones de tracto sucesivo + 01 + + + 17 + Operación acogida a alguno de los regímenes previstos en el Capítulo XI del Título IX (OSS e IOSS) + 01 + + + 18 + Recargo de equivalencia. + 01 + + + 19 + Operaciones de actividades incluidas en el Régimen Especial de Agricultura, Ganadería y Pesca (REAGYP) + 01 + + + 20 + Régimen simplificado + 01 + + + + + 01 + Operación de régimen general + 03 + + + 02 + Exportación + 03 + + + 03 + Operaciones a las que se aplique el régimen especial de bienes usados, objetos de arte, antigüedades y objetos de colección + 03 + + + 04 + Régimen especial oro de inversión + 03 + + + 05 + Régimen especial agencias de viajes + 03 + + + 06 + Régimen especial grupo de entidades en IGIC (Nivel Avanzado) + 01 + + + 07 + Régimen especial criterio de caja + 03 + + + 08 + Operaciones sujetas al IPSI / IVA (Impuesto sobre la Producción, los Servicios y la Importación / Impuesto sobre el Valor Añadido). + 03 + + + 09 + Facturación de las prestaciones de servicios de agencias de viaje que actúan como mediadoras en nombre y por cuenta ajena (D.A.4ª RD1619/2012) + 03 + + + 10 + Cobros por cuenta de terceros de honorarios profesionales o de derechos derivados de la propiedad industrial, de autor u otros por cuenta de sus socios, asociados o colegiados efectuados por sociedades, asociaciones, colegios profesionales u otras entidades que realicen estas funciones de cobro. + 03 + + + 11 + Operaciones de arrendamiento de local de negocio. + 03 + + + 14 + Factura con IGIC pendiente de devengo en certificaciones de obra cuyo destinatario sea una Administración Pública. + 03 + + + 15 + Factura con IGIC pendiente de devengo en operaciones de tracto sucesivo. + 03 + + + 17 + Régimen especial de comerciante minorista + 03 + + + 18 + Régimen especial del pequeño empresario o profesional + 03 + + + 19 + Operaciones interiores exentas por aplicación artículo 25 Ley 19/1994 + 03 + + + diff --git a/l10n_es_aeat_verifactu/data/aeat_sii_tax_agency_data.xml b/l10n_es_aeat_verifactu/data/aeat_verifactu_tax_agency_data.xml similarity index 79% rename from l10n_es_aeat_verifactu/data/aeat_sii_tax_agency_data.xml rename to l10n_es_aeat_verifactu/data/aeat_verifactu_tax_agency_data.xml index caeafa5c295..c63852a39f6 100644 --- a/l10n_es_aeat_verifactu/data/aeat_sii_tax_agency_data.xml +++ b/l10n_es_aeat_verifactu/data/aeat_verifactu_tax_agency_data.xml @@ -8,6 +8,6 @@ >https://prewww2.aeat.es/static_files/common/internet/dep/aplicaciones/es/aeat/tikeV1.0/cont/ws/SistemaFacturacion.wsdl https://prewww2.aeat.es/static_files/common/internet/dep/aplicaciones/es/aeat/tikeV1.0/cont/ws/SistemaFacturacion.wsdl + >https://prewww1.aeat.es/wlpl/TIKE-CONT/ws/SistemaFacturacion/VerifactuSOAP diff --git a/l10n_es_aeat_verifactu/data/neutralize.sql b/l10n_es_aeat_verifactu/data/neutralize.sql index e66e1b2ec36..18ac80f93b7 100644 --- a/l10n_es_aeat_verifactu/data/neutralize.sql +++ b/l10n_es_aeat_verifactu/data/neutralize.sql @@ -1,2 +1,2 @@ --- DISABLE SII ON COMPANIES +-- DISABLE VERIFACTU ON COMPANIES UPDATE res_company SET verifactu_test = true; diff --git a/l10n_es_aeat_verifactu/i18n/l10n_es_aeat_verifactu.pot b/l10n_es_aeat_verifactu/i18n/l10n_es_aeat_verifactu.pot new file mode 100644 index 00000000000..568b68adb8e --- /dev/null +++ b/l10n_es_aeat_verifactu/i18n/l10n_es_aeat_verifactu.pot @@ -0,0 +1,14 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * l10n_es_aeat_verifactu +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" diff --git a/l10n_es_aeat_verifactu/models/__init__.py b/l10n_es_aeat_verifactu/models/__init__.py index 0604daf79d1..c202ad7fd47 100644 --- a/l10n_es_aeat_verifactu/models/__init__.py +++ b/l10n_es_aeat_verifactu/models/__init__.py @@ -5,3 +5,6 @@ from . import aeat_tax_agency from . import account_fiscal_position from . import res_partner +from . import aeat_verifactu_map +from . import aeat_verifactu_map_lines +from . import aeat_verifactu_registration_keys diff --git a/l10n_es_aeat_verifactu/models/account_fiscal_position.py b/l10n_es_aeat_verifactu/models/account_fiscal_position.py index 284b0739fe4..da7df1d3cc5 100644 --- a/l10n_es_aeat_verifactu/models/account_fiscal_position.py +++ b/l10n_es_aeat_verifactu/models/account_fiscal_position.py @@ -1,7 +1,8 @@ # Copyright 2024 Aures TIC - Jose Zambudio +# Copyright 2024 Aures TIC - Almudena de La Puente # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo import fields, models +from odoo import api, fields, models class AccountFiscalPosition(models.Model): @@ -11,3 +12,27 @@ class AccountFiscalPosition(models.Model): related="company_id.verifactu_enabled", readonly=True, ) + verifactu_tax_key = fields.Selection( + selection="_get_verifactu_tax_keys", + ) + verifactu_registration_key = fields.Many2one( + "aeat.verifactu.registration.keys", + ondelete="restrict", + ) + + @api.model + def default_verifactu_tax_key(self): + return "01" + + @api.model + def _get_verifactu_tax_keys(self): + return [ + ("01", "Impuesto sobre el Valor Añadido (IVA)"), + ( + "02", + """Impuesto sobre la Producción, los Servicios y + la Importación (IPSI) de Ceuta y Melilla""", + ), + ("03", "Impuesto General Indirecto Canario (IGIC)"), + ("05", "Otros"), + ] diff --git a/l10n_es_aeat_verifactu/models/account_journal.py b/l10n_es_aeat_verifactu/models/account_journal.py index 5e32b6492ae..781836c2b96 100644 --- a/l10n_es_aeat_verifactu/models/account_journal.py +++ b/l10n_es_aeat_verifactu/models/account_journal.py @@ -1,3 +1,7 @@ +# Copyright 2024 Aures TIC - Jose Zambudio +# Copyright 2024 Aures TIC - Almudena de La Puente +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from odoo import _, api, fields, models from odoo.exceptions import ValidationError diff --git a/l10n_es_aeat_verifactu/models/account_move.py b/l10n_es_aeat_verifactu/models/account_move.py index 712283252e3..1ed4b42d477 100644 --- a/l10n_es_aeat_verifactu/models/account_move.py +++ b/l10n_es_aeat_verifactu/models/account_move.py @@ -5,6 +5,7 @@ import pytz from odoo import _, api, fields, models +from odoo.exceptions import UserError VERIFACTU_VALID_INVOICE_STATES = ["posted"] @@ -13,21 +14,8 @@ class AccountMove(models.Model): _name = "account.move" _inherit = ["account.move", "verifactu.mixin"] - verifactu_document_type = fields.Selection( - selection=lambda self: self._get_verifactu_docuyment_types(), - default="F1", - ) - - def _get_verifactu_docuyment_types(self): - return [ - ("F1", _("FACTURA (ART. 6, 7.2 Y 7.3 DEL RD 1619/2012)")), - ( - "F2", - _( - """FACTURA SIMPLIFICADA Y FACTURAS SIN IDENTIFICACIÓN DEL DESTINATARIO - (ART. 6.1.D RD 1619/2012)""" - ), - ), + verifactu_refund_specific_invoice_type = fields.Selection( + selection=[ ( "R1", _("FACTURA RECTIFICATIVA (Art 80.1 y 80.2 y error fundado en derecho)"), @@ -36,14 +24,23 @@ def _get_verifactu_docuyment_types(self): ("R3", _("FACTURA RECTIFICATIVA (Art. 80.4)")), ("R4", _("FACTURA RECTIFICATIVA (Resto)")), ("R5", _("FACTURA RECTIFICATIVA EN FACTURAS SIMPLIFICADAS")), - ( - "F3", - _( - """FACTURA EMITIDA EN SUSTITUCIÓN DE FACTURAS SIMPLIFICADAS FACTURADAS - Y DECLARADAS""" - ), - ), - ] + ], + help="Fill this field when the refund are one of the specific cases" + " of article 80 of LIVA for notifying to Vertifactu with the proper" + " invoice type.", + ) + + @api.depends("move_type") + def _compute_verifactu_refund_type(self): + for record in self: + if record.move_type == "out_refund": + record.verifactu_refund_type = "I" + else: + record.verifactu_refund_type = False + + @api.depends("amount_total") + def _compute_verifactu_macrodata(self): + return super()._compute_verifactu_macrodata() @api.depends( "company_id", @@ -63,12 +60,30 @@ def _compute_verifactu_enabled(self): else: invoice.verifactu_enabled = False + def _get_verifactu_document_type(self): + invoice_type = "" + if self.move_type in ["out_invoice", "out_refund"]: + is_simplified = self._is_aeat_simplified_invoice() + invoice_type = "F2" if is_simplified else "F1" + if self.move_type == "out_refund": + if self.verifactu_refund_specific_invoice_type: + invoice_type = self.verifactu_refund_specific_invoice_type + else: + invoice_type = "R5" if is_simplified else "R1" + return invoice_type + + def _get_document_amount_total(self): + return self.amount_total_signed + + def _get_verifactu_description(self): + return self.verifactu_description or self.company_id.verifactu_description + def _get_document_date(self): """ TODO: this method is the same in l10n_es_aeat_sii_oca, so I think that it should be directly in l10n_es_aeat """ - return self._change_date_format(self.invoice_date) + return self.invoice_date def _aeat_get_partner(self): """ @@ -107,14 +122,11 @@ def _get_document_serial_number(self): def _get_verifactu_issuer(self): return self.company_id.partner_id._parse_aeat_vat_info()[2] - def _get_verifactu_document_type(self): - return self.verifactu_document_type or "F1" - def _get_verifactu_amount_tax(self): - return self.amount_tax + return self.amount_tax_signed def _get_verifactu_amount_total(self): - return self.amount_total + return self.amount_total_signed def _get_verifactu_previous_hash(self): # TODO store it? search it by some kind of sequence? @@ -122,7 +134,15 @@ def _get_verifactu_previous_hash(self): def _get_verifactu_registration_date(self): # Date format must be ISO 8601 - return pytz.utc.localize(self.create_date).isoformat() + """ + TODO + enviamos fecha creación, fecha factura o fecha actual? + """ + return ( + pytz.utc.localize(self.create_date) + .astimezone() + .isoformat(timespec="seconds") + ) @api.model def _get_verifactu_hash_string(self): @@ -135,7 +155,7 @@ def _get_verifactu_hash_string(self): return "" issuerID = self._get_verifactu_issuer() serialNumber = self._get_document_serial_number() - expeditionDate = self._get_document_date() + expeditionDate = self._change_date_format(self._get_document_date()) documentType = self._get_verifactu_document_type() amountTax = self._get_verifactu_amount_tax() amountTotal = self._get_verifactu_amount_total() @@ -152,3 +172,210 @@ def _get_verifactu_hash_string(self): f"FechaHoraHusoGenRegistro={registrationDate}" ) return verifactu_hash_string + + def _get_aeat_invoice_dict_out(self, cancel=False): + """Build dict with data to send to AEAT WS for document types: + out_invoice and out_refund. + + :param cancel: It indicates if the dictionary is for sending a + cancellation of the document. + :return: documents (dict) : Dict XML with data for this document. + """ + self.ensure_one() + document_date = self._change_date_format(self._get_document_date()) + company = self.company_id + serial_number = self._get_document_serial_number() + taxes_dict, amount_tax, amount_total = self._get_verifactu_taxes_and_total() + company_vat = company.partner_id._parse_aeat_vat_info()[2] + verifactu_doc_type = self._get_verifactu_document_type() + registroAlta = {} + inv_dict = { + "IDVersion": self._get_verifactu_version(), + "IDFactura": { + "IDEmisorFactura": company_vat, + "NumSerieFactura": serial_number, + "FechaExpedicionFactura": document_date, + }, + "NombreRazonEmisor": self.company_id.name[0:120], + "TipoFactura": verifactu_doc_type, + } + if self.move_type == "out_refund": + inv_dict["TipoRectificativa"] = self.verifactu_refund_type + if self.verifactu_refund_type == "I": + inv_dict["FacturasRectificadas"] = [] + origin = self.reversed_entry_id + if origin: + orig_document_date = self._change_date_format( + origin._get_document_date() + ) + orig_serial_number = origin._get_document_serial_number() + origin_data = { + "IDFacturaRectificada": { + "IDEmisorFactura": company_vat, + "NumSerieFactura": orig_serial_number, + "FechaExpedicionFactura": orig_document_date, + } + } + inv_dict["FacturasRectificadas"].append(origin_data) + # inv_dict["ImporteRectificacion"] = { + # "BaseRectificada": abs(origin.amount_untaxed_signed), + # "CuotaRectificada": abs( + # origin.amount_total_signed - origin.amount_untaxed_signed + # ), + # } + inv_dict.update( + { + "DescripcionOperacion": self._get_verifactu_description(), + } + ) + if verifactu_doc_type not in ("F2", "R5"): + inv_dict.update( + { + "Destinatarios": self._get_receiver_dict(), + } + ) + + inv_dict.update( + { + "Desglose": taxes_dict, + "CuotaTotal": amount_tax, + "ImporteTotal": amount_total, + "Encadenamiento": self._get_chaining_invoice_dict(), + "SistemaInformatico": self._get_verifactu_developer_dict(), + "FechaHoraHusoGenRegistro": self._get_verifactu_registration_date(), + "TipoHuella": "01", # SHA-256 + "Huella": self.verifactu_hash, + } + ) + registroAlta.setdefault("RegistroAlta", inv_dict) + return registroAlta + + def _get_chaining_invoice_dict(self): + """ + TODO + si no es el primer registro, hay que enviar el registro anterior. + Cuando sepamos cuál es el registro anterior + prev_invoice = self._get_previous_invoice() + return + { + "RegistroAnterior" = { + "IDEmisorFactura": prev_invoice._get_verifactu_issuer() + "NumSerieFactura": prev_invoice._get_document_serial_number() + "FechaExpedicionFactura": prev_invoice._change_date_format( + prev_invoice._get_document_date()) + "Huella": prev_invoice.verifactu_hash + } + } + mientras tanto para pruebas vamos a decir siempre que es el primer registro + """ + return {"PrimerRegistro": "S"} + + def _get_verifactu_tax_dict(self, tax_line, tax_lines): + """Get the Verifactu tax dictionary for the passed tax line. + + :param self: Single invoice record. + :param tax_line: Tax line that is being analyzed. + :param tax_lines: Dictionary of processed invoice taxes for further operations + (like REQ). + :return: A dictionary with the corresponding Verifactu tax values. + """ + tax = tax_line["tax"] + tax_base_amount = tax_line["base"] + if tax.amount_type == "group": + tax_type = abs(tax.children_tax_ids.filtered("amount")[:1].amount) + else: + tax_type = abs(tax.amount) + tax_dict = { + "TipoImpositivo": str(tax_type), + "BaseImponibleOimporteNoSujeto": tax_base_amount, + } + key = "CuotaRepercutida" + tax_dict[key] = tax_line["amount"] + # Recargo de equivalencia + req_tax = self._get_verifactu_tax_req(tax) + if req_tax: + tax_dict["TipoRecargoEquivalencia"] = req_tax.amount + tax_dict["CuotaRecargoEquivalencia"] = tax_lines[req_tax]["amount"] + return tax_dict + + def _get_verifactu_tax_req(self, tax): + """Get the associated req tax for the specified tax. + + :param self: Single invoice record. + :param tax: Initial tax for searching for the RE linked tax. + :return: REQ tax (or empty recordset) linked to the provided tax. + """ + self.ensure_one() + document_date = self._get_document_fiscal_date() + taxes_req = self._get_aeat_taxes_map(["RE"], document_date) + re_lines = self.line_ids.filtered( + lambda x: tax in x.tax_ids and x.tax_ids & taxes_req + ) + req_tax = re_lines.mapped("tax_ids") & taxes_req + if len(req_tax) > 1: + raise UserError(_("There's a mismatch in taxes for RE. Check them.")) + return req_tax + + def _get_verifactu_taxes_and_total(self): + self.ensure_one() + taxes_dict = {} + taxes_dict.setdefault("DetalleDesglose", []) + tax_lines = self._get_aeat_tax_info() + document_date = self._get_document_fiscal_date() + taxes_S1 = self._get_aeat_taxes_map(["S1"], document_date) + taxes_S2 = self._get_aeat_taxes_map(["S2"], document_date) + taxes_N1 = self._get_aeat_taxes_map(["N1"], document_date) + taxes_N2 = self._get_aeat_taxes_map(["N2"], document_date) + breakdown_taxes = taxes_S1 + taxes_S2 + taxes_N1 + taxes_N2 + for tax_line in tax_lines.values(): + tax = tax_line["tax"] + if tax in breakdown_taxes: + operation_type = self._get_operation_type( + tax_line, taxes_S1, taxes_S2, taxes_N1, taxes_N2 + ) + tax_dict = { + "Impuesto": self.verifactu_tax_key, + "ClaveRegimen": self.verifactu_registration_key_code, + "CalificacionOperacion": operation_type, + } + # si es exenta: + # "OperacionExenta": "", # TODO + tax_dict.update(self._get_verifactu_tax_dict(tax_line, tax_lines)) + taxes_dict["DetalleDesglose"].append(tax_dict) + return taxes_dict, self.amount_tax_signed, self.amount_total_signed + + def _get_operation_type(self, tax_line, taxes_S1, taxes_S2, taxes_N1, taxes_N2): + """ + S1 Operación Sujeta y No exenta - Sin inversión del sujeto pasivo. + S2 Operación Sujeta y No exenta - Con Inversión del sujeto pasivo + N1 Operación No Sujeta artículo 7, 14, otros. + N2 Operación No Sujeta por Reglas de localización. + """ + tax = tax_line["tax"] + if tax in taxes_S1: + return "S1" + elif tax in taxes_S2: + return "S2" + elif tax in taxes_N1: + return "N1" + elif tax in taxes_N2: + return "N2" + return "S1" + + def _get_receiver_dict(self): + self.ensure_one() + receiver = self._aeat_get_partner() + vat_info = receiver._parse_aeat_vat_info() + return { + "IDDestinatario": { + "NombreRazon": receiver.name, + "NIF": vat_info[2], + # "IDOtro": { + # "IDType": vat_info[1], + # "ID": vat_info[0], + # } + } + } + + def cancel_verifactu(self): + raise NotImplementedError diff --git a/l10n_es_aeat_verifactu/models/aeat_tax_agency.py b/l10n_es_aeat_verifactu/models/aeat_tax_agency.py index c3fdf3f5e2c..adbc1b91a0b 100644 --- a/l10n_es_aeat_verifactu/models/aeat_tax_agency.py +++ b/l10n_es_aeat_verifactu/models/aeat_tax_agency.py @@ -1,4 +1,5 @@ # Copyright 2024 Aures Tic - Jose Zambudio +# Copyright 2024 Aures TIC - Almudena de La Puente # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). from odoo import fields, models @@ -8,8 +9,8 @@ "out_refund": "verifactu_wsdl_out", } VERIFACTU_PORT_NAME_MAPPING = { - "out_invoice": "SuministroInformacion", - "out_refund": "SuministroInformacion", + "out_invoice": "SistemaVerifactu", + "out_refund": "SistemaVerifactu", } @@ -28,7 +29,6 @@ def _connect_params_verifactu(self, mapping_key, company): port_name = VERIFACTU_PORT_NAME_MAPPING[mapping_key] address = getattr(self, wsdl_test_field) if company.verifactu_test else False if not address and company.verifactu_test: - # If not test address is provides we try to get it using the port name. port_name += "Pruebas" return { "wsdl": getattr(self, wsdl_field), diff --git a/l10n_es_aeat_verifactu/models/aeat_verifactu_map.py b/l10n_es_aeat_verifactu/models/aeat_verifactu_map.py new file mode 100644 index 00000000000..16c5779d5ed --- /dev/null +++ b/l10n_es_aeat_verifactu/models/aeat_verifactu_map.py @@ -0,0 +1,54 @@ +# Copyright 2024 Aures TIC - Almudena de La Puente +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _, api, exceptions, fields, models + + +class AeatVerifactuMap(models.Model): + _name = "aeat.verifactu.map" + _description = "Aeat Verifactu Map" + + name = fields.Char(string="Model", required=True) + date_from = fields.Date() + date_to = fields.Date() + map_lines = fields.One2many( + comodel_name="aeat.verifactu.map.lines", + inverse_name="verifactu_map_id", + string="Lines", + ) + + @api.constrains("date_from", "date_to") + def _unique_date_range(self): + for record in self: + record._unique_date_range_one() + + def _unique_date_range_one(self): + # Based in l10n_es_aeat module + domain = [("id", "!=", self.id)] + if self.date_from and self.date_to: + domain += [ + "|", + "&", + ("date_from", "<=", self.date_to), + ("date_from", ">=", self.date_from), + "|", + "&", + ("date_to", "<=", self.date_to), + ("date_to", ">=", self.date_from), + "|", + "&", + ("date_from", "=", False), + ("date_to", ">=", self.date_from), + "|", + "&", + ("date_to", "=", False), + ("date_from", "<=", self.date_to), + ] + elif self.date_from: + domain += [("date_to", ">=", self.date_from)] + elif self.date_to: + domain += [("date_from", "<=", self.date_to)] + date_lst = self.search(domain) + if date_lst: + raise exceptions.UserError( + _("Error! The dates of the record overlap with an existing " "record.") + ) diff --git a/l10n_es_aeat_verifactu/models/aeat_verifactu_map_lines.py b/l10n_es_aeat_verifactu/models/aeat_verifactu_map_lines.py new file mode 100644 index 00000000000..feb808a70de --- /dev/null +++ b/l10n_es_aeat_verifactu/models/aeat_verifactu_map_lines.py @@ -0,0 +1,18 @@ +# Copyright 2024 Aures TIC - Almudena de La Puente +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class AeatVertiactuMapLines(models.Model): + _name = "aeat.verifactu.map.lines" + _description = "Aeat Verifactu Map Lines" + + code = fields.Char(required=True) + name = fields.Char() + taxes = fields.Many2many(comodel_name="account.tax.template") + verifactu_map_id = fields.Many2one( + comodel_name="aeat.verifactu.map", + string="Aeat Verifactu Map", + ondelete="cascade", + ) diff --git a/l10n_es_aeat_verifactu/models/aeat_verifactu_registration_keys.py b/l10n_es_aeat_verifactu/models/aeat_verifactu_registration_keys.py new file mode 100644 index 00000000000..512e446e168 --- /dev/null +++ b/l10n_es_aeat_verifactu/models/aeat_verifactu_registration_keys.py @@ -0,0 +1,27 @@ +# Copyright 2024 Aures TIC - Almudena de La Puente +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class AeatVerifactuMappingRegistrationKeys(models.Model): + _name = "aeat.verifactu.registration.keys" + _description = "Aeat Verifactu Registration Keys" + + code = fields.Char(required=True, size=2) + name = fields.Char(required=True) + verifactu_tax_key = fields.Selection( + selection="_get_verifactu_tax_keys", + required=True, + ) + + def name_get(self): + vals = [] + for record in self: + name = "[{}]-{}".format(record.code, record.name) + vals.append(tuple([record.id, name])) + return vals + + @api.model + def _get_verifactu_tax_keys(self): + return self.env["account.fiscal.position"]._get_verifactu_tax_keys() diff --git a/l10n_es_aeat_verifactu/models/res_company.py b/l10n_es_aeat_verifactu/models/res_company.py index e725d621a35..54321f241b2 100644 --- a/l10n_es_aeat_verifactu/models/res_company.py +++ b/l10n_es_aeat_verifactu/models/res_company.py @@ -9,3 +9,8 @@ class ResCompany(models.Model): verifactu_enabled = fields.Boolean(string="Enable veri*FACTU") verifactu_test = fields.Boolean(string="Is it the veri*FACTU test environment?") + verifactu_description = fields.Text( + default="/", + size=500, + help="The description for Verifactu invoices if not set", + ) diff --git a/l10n_es_aeat_verifactu/models/verifactu_mixin.py b/l10n_es_aeat_verifactu/models/verifactu_mixin.py index 7b40f0343de..b9fce75a63b 100644 --- a/l10n_es_aeat_verifactu/models/verifactu_mixin.py +++ b/l10n_es_aeat_verifactu/models/verifactu_mixin.py @@ -2,15 +2,38 @@ # Copyright 2024 Aures TIC - Almudena de La Puente # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import json +import logging from hashlib import sha256 +from requests import Session + from odoo import _, api, fields, models -from odoo.exceptions import UserError +from odoo.exceptions import UserError, ValidationError +from odoo.modules.registry import Registry +from odoo.tools.float_utils import float_compare from odoo.addons.l10n_es_aeat.models.aeat_mixin import round_by_keys -VERIFACTU_VERSION = "0.12.2" +########################################### +# revisar los imports que no hagan falta +# cuando funcione bien el _connect_aeat sin tener que poner +# el forbid_entities, y se pueda borrar la función en +# este fichero para usar la del aeat_mixin + + +_logger = logging.getLogger(__name__) + +try: + from zeep import Client, Settings + from zeep.plugins import HistoryPlugin + from zeep.transports import Transport +except (ImportError, IOError) as err: + _logger.debug(err) + +VERIFACTU_VERSION = "1.0" VERIFACTU_DATE_FORMAT = "%d-%m-%Y" +VERIFACTU_MACRODATA_LIMIT = 100000000.0 class VerifactuMixin(models.AbstractModel): @@ -24,10 +47,63 @@ class VerifactuMixin(models.AbstractModel): ) verifactu_hash_string = fields.Char(compute="_compute_verifactu_hash") verifactu_hash = fields.Char(compute="_compute_verifactu_hash") + verifactu_refund_type = fields.Selection( + selection=[ + # ('S', 'By substitution'), - en sii no está soportado, aquí igual? + ("I", "By differences"), + ], + compute="_compute_verifactu_refund_type", + store=True, + readonly=False, + ) + verifactu_description = fields.Text( + copy=False, + ) + verifactu_macrodata = fields.Boolean( + string="MacroData", + help="Check to confirm that the document has an absolute amount " + "greater o equal to 100 000 000,00 euros.", + compute="_compute_verifactu_macrodata", + ) + verifactu_csv = fields.Char(copy=False, readonly=True) + verifactu_return = fields.Text(copy=False, readonly=True) + verifactu_registration_key = fields.Many2one( + comodel_name="aeat.verifactu.registration.keys", + compute="_compute_verifactu_registration_key", + store=True, + readonly=False, + ) + verifactu_tax_key = fields.Selection( + string="Verifactu tax key", + selection="_get_verifactu_tax_keys", + compute="_compute_verifactu_tax_key", + store=True, + readonly=False, + ) + verifactu_registration_key_code = fields.Char( + compute="_compute_verifactu_registration_key_code", + readonly=True, + string="Verifactu Code", + ) def _compute_verifactu_enabled(self): raise NotImplementedError + def _compute_verifactu_macrodata(self): + for document in self: + document.verifactu_macrodata = ( + float_compare( + abs(document._get_document_amount_total()), + VERIFACTU_MACRODATA_LIMIT, + precision_digits=2, + ) + >= 0 + ) + + @api.model + def _get_verifactu_tax_keys(self): + return self.env["account.fiscal.position"]._get_verifactu_tax_keys() + def _connect_params_aeat(self, mapping_key): self.ensure_one() agency = self.company_id.tax_agency_id @@ -53,16 +129,11 @@ def _get_aeat_header(self, tipo_comunicacion=False, cancellation=False): _("No VAT configured for the company '{}'").format(self.company_id.name) ) header = { - "IDVersion": VERIFACTU_VERSION, "ObligadoEmision": { "NombreRazon": self.company_id.name[0:120], "NIF": self.company_id.partner_id._parse_aeat_vat_info()[2], }, - # "TipoRegistroAEAT": , - # "FechaFinVeriactu": , } - # if not cancellation: - # header.update({"TipoComunicacion": tipo_comunicacion}) return header def _get_aeat_invoice_dict(self): @@ -76,50 +147,47 @@ def _get_aeat_invoice_dict(self): round_by_keys( inv_dict, [ - "BaseImponible", + "BaseImponibleOimporteNoSujeto", "CuotaRepercutida", - "CuotaSoportada", "TipoRecargoEquivalencia", "CuotaRecargoEquivalencia", - "ImportePorArticulos7_14_Otros", - "ImporteTAIReglasLocalizacion", + "CuotaTotal", "ImporteTotal", "BaseRectificada", "CuotaRectificada", - "CuotaDeducible", - "ImporteCompensacionREAGYP", ], ) return inv_dict def _get_aeat_invoice_dict_out(self, cancel=False): - """Build dict with data to send to AEAT WS for document types: - out_invoice and out_refund. + raise NotImplementedError - :param cancel: It indicates if the dictionary is for sending a - cancellation of the document. - :return: documents (dict) : Dict XML with data for this document. + def _get_verifactu_developer_dict(self): """ - self.ensure_one() - document_date = self._change_date_format(self._get_document_date()) - company = self.company_id - fiscal_year = self._get_document_fiscal_year() - period = self._get_document_period() - serial_number = self._get_document_serial_number() - inv_dict = { - "IDFactura": { - "IDEmisorFactura": { - "NIF": company.partner_id._parse_aeat_vat_info()[2] - }, - "NumSerieFactura": serial_number, - "FechaExpedicionFactura": document_date, - }, - "PeriodoLiquidacion": { - "Ejercicio": fiscal_year, - "Periodo": period, + TODO + Datos del desarrollador del sistema informático + """ + return { + "NombreRazon": _("Asoc Española de Odoo"), + "NIF": "G87846952", + "NombreSistemaInformatico": "odoo", + "IdSistemaInformatico": "11", + "Version": "1.0", + "NumeroInstalacion": "1", + "TipoUsoPosibleSoloVerifactu": "N", + "TipoUsoPosibleMultiOT": "S", + "IndicadorMultiplesOT": "S", + "IDOtro": { + "IDType": "", + "ID": "", }, } - return inv_dict + + def _get_previous_invoice(self): + raise NotImplementedError + + def _get_chaining_invoice_dict(self): + raise NotImplementedError def _aeat_check_exceptions(self): """Inheritable method for exceptions control when sending veri*FACTU invoices.""" @@ -147,3 +215,219 @@ def _compute_verifactu_hash(self): record.verifactu_hash_string = verifactu_hash_values hash_string = sha256(verifactu_hash_values.encode("utf-8")) record.verifactu_hash = hash_string.hexdigest().upper() + + def _get_verifactu_document_type(self): + raise NotImplementedError() + + def _get_verifactu_description(self): + raise NotImplementedError() + + def _get_verifactu_taxes_and_total(self): + raise NotImplementedError + + def _get_verifactu_version(self): + return VERIFACTU_VERSION + + def _get_receiver_dict(self): + raise NotImplementedError + + def _compute_verifactu_refund_type(self): + self.verifactu_refund_type = False + + def _is_aeat_simplified_invoice(self): + """Inheritable method to allow control when an + invoice are simplified or normal""" + partner = self._aeat_get_partner() + return partner.aeat_simplified_invoice + + def _get_verifactu_jobs_field_name(self): + raise NotImplementedError + + def send_verifactu(self): + """General public method for filtering out of the starting recordset the records + that shouldn't be sent to Verifactu: + + - Documents of companies with Verifactu not enabled (through verifactu_enabled). + - Documents not applicable to be sent to Verifactu (through verifactu_enabled). + - Documents in non applicable states (for example, cancelled invoices). + - Documents already sent to Verifactu. + - Documents with sending jobs pending to be executed. + """ + valid_states = self._get_valid_document_states() + for document in self: + if ( + not document.verifactu_enabled + or document.state not in valid_states + or document.aeat_state in ["sent", "cancelled"] + ): + continue + document._process_verifactu_send() + + def _process_verifactu_send(self): + """ + Process document sending to Verifactu + TODO : use connector + """ + for record in self: + record.confirm_verifactu_one_document() + + def confirm_verifactu_one_document(self): + self.sudo()._send_document_to_verifactu() + + def _send_document_to_verifactu(self): + for document in self.filtered( + lambda i: i.state in self._get_valid_document_states() + ): + if document.aeat_state == "not_sent": + tipo_comunicacion = "A0" + else: + tipo_comunicacion = "A1" + header = document._get_aeat_header(tipo_comunicacion) + doc_vals = { + "aeat_header_sent": json.dumps(header, indent=4), + } + try: + inv_dict = document._get_aeat_invoice_dict() + except Exception as fault: + raise ValidationError(fault) from fault + try: + mapping_key = document._get_mapping_key() + serv = document._connect_aeat(mapping_key) + doc_vals["aeat_content_sent"] = json.dumps(inv_dict, indent=4) + if mapping_key in ["out_invoice", "out_refund"]: + res = serv.RegFactuSistemaFacturacion(header, inv_dict) + res_line = res["RespuestaLinea"][0] + if res["EstadoEnvio"] == "Correcto": + doc_vals.update( + { + "aeat_state": "sent", + "verifactu_csv": res["CSV"], + "aeat_send_failed": False, + } + ) + elif ( + res["EstadoEnvio"] == "ParcialmenteCorrecto" + and res_line["EstadoRegistro"] == "AceptadoConErrores" + ): + doc_vals.update( + { + "aeat_state": "sent_w_errors", + "verifactu_csv": res["CSV"], + "aeat_send_failed": True, + } + ) + else: + doc_vals["aeat_send_failed"] = True + doc_vals["verifactu_return"] = res + send_error = False + if res_line["CodigoErrorRegistro"]: + send_error = "{} | {}".format( + str(res_line["CodigoErrorRegistro"]), + str(res_line["DescripcionErrorRegistro"]), + ) + doc_vals["aeat_send_error"] = send_error + document.write(doc_vals) + except Exception as fault: + new_cr = Registry(self.env.cr.dbname).cursor() + env = api.Environment(new_cr, self.env.uid, self.env.context) + document = env[document._name].browse(document.id) + doc_vals.update( + { + "aeat_send_failed": True, + "aeat_send_error": repr(fault)[:200], + "verifactu_return": repr(fault), + "aeat_content_sent": json.dumps(inv_dict, indent=4), + } + ) + document.write(doc_vals) + new_cr.commit() + new_cr.close() + raise ValidationError(fault) from fault + + def _connect_aeat(self, mapping_key): + # de momento no puedo usar la del aeat_mixin porque si no pongo + # forbid_entities en settings del Client da error de entities forbiden + self.ensure_one() + public_crt, private_key = self.env["l10n.es.aeat.certificate"].get_certificates( + company=self.company_id + ) + params = self._connect_params_aeat(mapping_key) + session = Session() + session.cert = (public_crt, private_key) + transport = Transport(session=session) + history = HistoryPlugin() + settings = Settings(forbid_entities=False) + client = Client( + wsdl=params["wsdl"], + transport=transport, + plugins=[history], + settings=settings, + ) + return self._bind_service(client, params["port_name"], params["address"]) + + def _bind_service(self, client, port_name, address=None): + self.ensure_one() + service = client._get_service("sfVerifactu") + port = client._get_port(service, port_name) + address = address or port.binding_options["address"] + return client.create_service(port.binding.name, address) + + @api.model + def _get_aeat_taxes_map(self, codes, date): + """Return the codes that correspond to verifactu map line codes. + + :param codes: List of code strings to get the mapping. + :param date: Date to map + :return: Recordset with the corresponding codes + """ + map_obj = self.env["aeat.verifactu.map"].sudo().with_context(active_test=False) + verifactu_map = map_obj.search( + [ + "|", + ("date_from", "<=", date), + ("date_from", "=", False), + "|", + ("date_to", ">=", date), + ("date_to", "=", False), + ], + limit=1, + ) + tax_templates = verifactu_map.map_lines.filtered( + lambda x: x.code in codes + ).taxes + return self.company_id.get_taxes_from_templates(tax_templates) + + @api.depends("fiscal_position_id") + def _compute_verifactu_tax_key(self): + for document in self: + document.verifactu_tax_key = ( + document.fiscal_position_id.verifactu_tax_key or "01" + ) + + @api.depends("fiscal_position_id") + def _compute_verifactu_registration_key(self): + for document in self: + if document.fiscal_position_id: + key = document.fiscal_position_id.verifactu_registration_key + if key: + document.verifactu_registration_key = key + else: + domain = [ + ("code", "=", "01"), + ( + "verifactu_tax_key", + "=", + "iva", + ), + ] + verifactu_key_obj = self.env["aeat.verifactu.registration.keys"] + document.verifactu_registration_key = verifactu_key_obj.search( + domain, limit=1 + ) + + @api.depends("verifactu_registration_key") + def _compute_verifactu_registration_key_code(self): + for record in self: + record.verifactu_registration_key_code = ( + record.verifactu_registration_key.code + ) diff --git a/l10n_es_aeat_verifactu/readme/CONFIGURE.rst b/l10n_es_aeat_verifactu/readme/CONFIGURE.rst index e69de29bb2d..f07af189f6a 100644 --- a/l10n_es_aeat_verifactu/readme/CONFIGURE.rst +++ b/l10n_es_aeat_verifactu/readme/CONFIGURE.rst @@ -0,0 +1,41 @@ +Para configurar este módulo es necesario: + +#. En la compañia se almacenan las URLs del servicio SOAP de hacienda. + Estas URLs pueden cambiar según comunidades +#. Los certificados deben alojarse en una carpeta accesible por la instalación + de Odoo. +#. Preparar el certificado. El certificado enviado por la FMNT es en formato + p12, este certificado no se puede usar directamente con Zeep. Se tiene que + extraer la clave pública y la clave privada. + +En Linux se pueden usar los siguientes comandos: + +- Clave pública: "openssl pkcs12 -in Certificado.p12 -nokeys -out publicCert.crt -nodes" +- Clave privada: "openssl pkcs12 -in Certifcado.p12 -nocerts -out privateKey.pem -nodes" + +Además, el módulo `queue_job` necesita estar configurado de una de estas formas: + +#. Ajustando variables de entorno: + + ODOO_QUEUE_JOB_CHANNELS=root:4 + + u otro canal de configuración. Por defecto es root:1 + + Si xmlrpc_port no está definido: ODOO_QUEUE_JOB_PORT=8069 + +#. Otra alternativa es usuando un fichero de configuración: + + [options] + (...) + workers = 4 + server_wide_modules = web,base_sparse_field,queue_job + + (...) + [queue_job] + channels = root:4 + +#. Por último, arrancando Odoo con --load=web,base_sparse_field,queue_job y --workers más grande que 1. + +Más información http://odoo-connector.com + +#. Establecer en las posiciones fiscales la clave de impuestos y la clave de registro verifactu. diff --git a/l10n_es_aeat_verifactu/readme/CONTRIBUTORS.rst b/l10n_es_aeat_verifactu/readme/CONTRIBUTORS.rst index 59e855e1d69..29280226b91 100644 --- a/l10n_es_aeat_verifactu/readme/CONTRIBUTORS.rst +++ b/l10n_es_aeat_verifactu/readme/CONTRIBUTORS.rst @@ -1,3 +1,4 @@ * Jose Zambudio +* Almudena de La Puente * Laura Cazorla * Andreu Orensanz diff --git a/l10n_es_aeat_verifactu/readme/INSTALL.rst b/l10n_es_aeat_verifactu/readme/INSTALL.rst index e69de29bb2d..0b8967682d7 100644 --- a/l10n_es_aeat_verifactu/readme/INSTALL.rst +++ b/l10n_es_aeat_verifactu/readme/INSTALL.rst @@ -0,0 +1,8 @@ +Para instalar esté módulo necesita: + +#. Libreria Python Zeep, se puede instalar con el comando 'pip install zeep' +#. Libreria Python Requests, se puede instalar con el comando 'pip install requests' + +y el módulo `queue_job` que se encuentra en: + +https://github.com/OCA/queue diff --git a/l10n_es_aeat_verifactu/readme/ROADMAP.rst b/l10n_es_aeat_verifactu/readme/ROADMAP.rst index 18f879648d8..19a5faee1a4 100644 --- a/l10n_es_aeat_verifactu/readme/ROADMAP.rst +++ b/l10n_es_aeat_verifactu/readme/ROADMAP.rst @@ -1,5 +1,8 @@ - * Refactorización SII en l10n_es_aeat - * Creación documento a enviar a Veri*FACTU - * Creación cabecera Veri*FACTU - * Conexión WSDL - * Queue + Encadenamiento + * Refactorización SII-Verifactu en l10n_es_aeat cuando estén todos los procesos claros + * Envío de Facturas simplificadas, exentas, a terceros.. + * Encadenamiento, obtener factura anterior y almacenamiento del hash inalterable. + * Datas de mapeos de impuestos, ya hay algunos. + * Datos reales del desarrollador del sistema informático. + * Envío con Queue. + * Modificación de facturas enviadas. + * Anulación de facturas enviadas. diff --git a/l10n_es_aeat_verifactu/security/ir.model.access.csv b/l10n_es_aeat_verifactu/security/ir.model.access.csv new file mode 100644 index 00000000000..08b7ca05456 --- /dev/null +++ b/l10n_es_aeat_verifactu/security/ir.model.access.csv @@ -0,0 +1,8 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_model_aeat_verifactu_map_admin,aeat.verifactu.map admin,model_aeat_verifactu_map,base.group_system,1,1,1,1 +access_model_aeat_verifactu_map_aeat,aeat.verifactu.map aeat,model_aeat_verifactu_map,l10n_es_aeat.group_account_aeat,1,0,0,0 +access_model_aeat_verifactu_map_lines_admin,aeat.verifactu.map.lines admin,model_aeat_verifactu_map_lines,base.group_system,1,1,1,1 +access_model_aeat_verifactu_map_lines_aeat,aeat.verifactu.map.lines aeat,model_aeat_verifactu_map_lines,l10n_es_aeat.group_account_aeat,1,0,0,0 +access_model_aeat_verifactu_registration_keys_admin,aeat.verifactu.registration.keys admin,model_aeat_verifactu_registration_keys,base.group_system,1,1,1,1 +access_model_aeat_verifactu_registration_keys_aeat,aeat.verifactu.registration.keys aeat,model_aeat_verifactu_registration_keys,l10n_es_aeat.group_account_aeat,1,0,0,0 +access_model_aeat_verifactu_registration_keys_aeat_account,aeat.verifactu.registration.keys aeat,model_aeat_verifactu_registration_keys,account.group_account_invoice,1,0,0,0 diff --git a/l10n_es_aeat_verifactu/static/description/index.html b/l10n_es_aeat_verifactu/static/description/index.html index 80c088acd59..d1abdd6c295 100644 --- a/l10n_es_aeat_verifactu/static/description/index.html +++ b/l10n_es_aeat_verifactu/static/description/index.html @@ -374,30 +374,90 @@

Comunicación Veri*FACTU

Table of contents

+
+

Installation

+

Para instalar esté módulo necesita:

+
    +
  1. Libreria Python Zeep, se puede instalar con el comando ‘pip install zeep’
  2. +
  3. Libreria Python Requests, se puede instalar con el comando ‘pip install requests’
  4. +
+

y el módulo queue_job que se encuentra en:

+

https://github.com/OCA/queue

+
+
+

Configuration

+

Para configurar este módulo es necesario:

+
    +
  1. En la compañia se almacenan las URLs del servicio SOAP de hacienda. +Estas URLs pueden cambiar según comunidades
  2. +
  3. Los certificados deben alojarse en una carpeta accesible por la instalación +de Odoo.
  4. +
  5. Preparar el certificado. El certificado enviado por la FMNT es en formato +p12, este certificado no se puede usar directamente con Zeep. Se tiene que +extraer la clave pública y la clave privada.
  6. +
+

En Linux se pueden usar los siguientes comandos:

+
    +
  • Clave pública: “openssl pkcs12 -in Certificado.p12 -nokeys -out publicCert.crt -nodes”
  • +
  • Clave privada: “openssl pkcs12 -in Certifcado.p12 -nocerts -out privateKey.pem -nodes”
  • +
+

Además, el módulo queue_job necesita estar configurado de una de estas formas:

+
    +
  1. Ajustando variables de entorno:

    +
    +

    ODOO_QUEUE_JOB_CHANNELS=root:4

    +
    +

    u otro canal de configuración. Por defecto es root:1

    +

    Si xmlrpc_port no está definido: ODOO_QUEUE_JOB_PORT=8069

    +
  2. +
  3. Otra alternativa es usuando un fichero de configuración:

    +
    +

    [options] +(…) +workers = 4 +server_wide_modules = web,base_sparse_field,queue_job

    +

    (…) +[queue_job] +channels = root:4

    +
    +
  4. +
  5. Por último, arrancando Odoo con –load=web,base_sparse_field,queue_job y –workers más grande que 1.

    +
  6. +
+

Más información http://odoo-connector.com

+
    +
  1. Establecer en las posiciones fiscales la clave de impuestos y la clave de registro verifactu.
  2. +
+
-

Known issues / Roadmap

+

Known issues / Roadmap

    -
  • Refactorización SII en l10n_es_aeat
  • -
  • Creación documento a enviar a Veri*FACTU
  • -
  • Creación cabecera Veri*FACTU
  • -
  • Conexión WSDL
  • -
  • Queue + Encadenamiento
  • +
  • Refactorización SII-Verifactu en l10n_es_aeat cuando estén todos los procesos claros
  • +
  • Envío de Facturas simplificadas, exentas, a terceros..
  • +
  • Encadenamiento, obtener factura anterior y almacenamiento del hash inalterable.
  • +
  • Datas de mapeos de impuestos, ya hay algunos.
  • +
  • Datos reales del desarrollador del sistema informático.
  • +
  • Envío con Queue.
  • +
  • Modificación de facturas enviadas.
  • +
  • Anulación de facturas enviadas.
-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -405,24 +465,25 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Aures Tic
  • ForgeFlow
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association diff --git a/l10n_es_aeat_verifactu/tests/json/verifactu_out_invoice_s_iva10b_s_iva21s_dict.json b/l10n_es_aeat_verifactu/tests/json/verifactu_out_invoice_s_iva10b_s_iva21s_dict.json new file mode 100644 index 00000000000..c0e4e0bf4cf --- /dev/null +++ b/l10n_es_aeat_verifactu/tests/json/verifactu_out_invoice_s_iva10b_s_iva21s_dict.json @@ -0,0 +1,59 @@ +{ + "RegistroAlta": { + "IDVersion": 1.0, + "IDFactura": { + "IDEmisorFactura": "G87846952", + "NumSerieFactura": "TEST001", + "FechaExpedicionFactura": "01-01-2024" + }, + "NombreRazonEmisor": "Spanish test company", + "TipoFactura": "F1", + "DescripcionOperacion": "/", + "Destinatarios": { + "IDDestinatario": { + "NombreRazon": "Test partner", + "NIF": "89890001K" + } + }, + "Desglose": { + "DetalleDesglose": [ + { + "Impuesto": "01", + "ClaveRegimen": "01", + "CalificacionOperacion": "S1", + "TipoImpositivo": "10.0", + "BaseImponibleOimporteNoSujeto": 100.0, + "CuotaRepercutida": 10.0 + }, + { + "Impuesto": "01", + "ClaveRegimen": "01", + "CalificacionOperacion": "S1", + "TipoImpositivo": "21.0", + "BaseImponibleOimporteNoSujeto": 200.0, + "CuotaRepercutida": 42.0 + } + ] + }, + "CuotaTotal": 52.0, + "ImporteTotal": 352.0, + "Encadenamiento": { + "PrimerRegistro": "S" + }, + "SistemaInformatico": { + "NombreRazon": "Asoc Española de Odoo", + "NIF": "G87846952", + "NombreSistemaInformatico": "odoo", + "IdSistemaInformatico": "11", + "Version": "1.0", + "NumeroInstalacion": "1", + "TipoUsoPosibleSoloVerifactu": "N", + "TipoUsoPosibleMultiOT": "S", + "IndicadorMultiplesOT": "S", + "IDOtro": { + "IDType": "", + "ID": "" + } + } + } +} diff --git a/l10n_es_aeat_verifactu/tests/json/verifactu_out_invoice_s_iva21s_s_req52_dict.json b/l10n_es_aeat_verifactu/tests/json/verifactu_out_invoice_s_iva21s_s_req52_dict.json new file mode 100644 index 00000000000..83b9f52390c --- /dev/null +++ b/l10n_es_aeat_verifactu/tests/json/verifactu_out_invoice_s_iva21s_s_req52_dict.json @@ -0,0 +1,53 @@ +{ + "RegistroAlta": { + "IDVersion": 1.0, + "IDFactura": { + "IDEmisorFactura": "G87846952", + "NumSerieFactura": "TEST001", + "FechaExpedicionFactura": "01-01-2024" + }, + "NombreRazonEmisor": "Spanish test company", + "TipoFactura": "F1", + "DescripcionOperacion": "/", + "Destinatarios": { + "IDDestinatario": { + "NombreRazon": "Test partner", + "NIF": "89890001K" + } + }, + "Desglose": { + "DetalleDesglose": [ + { + "Impuesto": "01", + "ClaveRegimen": "01", + "CalificacionOperacion": "S1", + "TipoImpositivo": "21.0", + "BaseImponibleOimporteNoSujeto": 200.0, + "CuotaRepercutida": 42.0, + "TipoRecargoEquivalencia": 5.2, + "CuotaRecargoEquivalencia": 10.4 + } + ] + }, + "CuotaTotal": 52.4, + "ImporteTotal": 252.4, + "Encadenamiento": { + "PrimerRegistro": "S" + }, + "SistemaInformatico": { + "NombreRazon": "Asoc Española de Odoo", + "NIF": "G87846952", + "NombreSistemaInformatico": "odoo", + "IdSistemaInformatico": "11", + "Version": "1.0", + "NumeroInstalacion": "1", + "TipoUsoPosibleSoloVerifactu": "N", + "TipoUsoPosibleMultiOT": "S", + "IndicadorMultiplesOT": "S", + "IDOtro": { + "IDType": "", + "ID": "" + } + } + } +} diff --git a/l10n_es_aeat_verifactu/tests/json/verifactu_out_refund_s_iva10b_s_iva10b_s_iva21s_dict.json b/l10n_es_aeat_verifactu/tests/json/verifactu_out_refund_s_iva10b_s_iva10b_s_iva21s_dict.json new file mode 100644 index 00000000000..92ea6ec68e0 --- /dev/null +++ b/l10n_es_aeat_verifactu/tests/json/verifactu_out_refund_s_iva10b_s_iva10b_s_iva21s_dict.json @@ -0,0 +1,61 @@ +{ + "RegistroAlta": { + "IDVersion": 1.0, + "IDFactura": { + "IDEmisorFactura": "G87846952", + "NumSerieFactura": "TEST001", + "FechaExpedicionFactura": "01-01-2024" + }, + "NombreRazonEmisor": "Spanish test company", + "TipoFactura": "R1", + "TipoRectificativa": "I", + "DescripcionOperacion": "/", + "Destinatarios": { + "IDDestinatario": { + "NombreRazon": "Test partner", + "NIF": "89890001K" + } + }, + "Desglose": { + "DetalleDesglose": [ + { + "Impuesto": "01", + "ClaveRegimen": "01", + "CalificacionOperacion": "S1", + "TipoImpositivo": "10.0", + "BaseImponibleOimporteNoSujeto": -200.0, + "CuotaRepercutida": -20.0 + }, + { + "Impuesto": "01", + "ClaveRegimen": "01", + "CalificacionOperacion": "S1", + "TipoImpositivo": "21.0", + "BaseImponibleOimporteNoSujeto": -200.0, + "CuotaRepercutida": -42.0 + } + ] + }, + "CuotaTotal": -62.0, + "ImporteTotal": -462.0, + "Encadenamiento": { + "PrimerRegistro": "S" + }, + "FacturasRectificadas": [], + "SistemaInformatico": { + "NombreRazon": "Asoc Española de Odoo", + "NIF": "G87846952", + "NombreSistemaInformatico": "odoo", + "IdSistemaInformatico": "11", + "Version": "1.0", + "NumeroInstalacion": "1", + "TipoUsoPosibleSoloVerifactu": "N", + "TipoUsoPosibleMultiOT": "S", + "IndicadorMultiplesOT": "S", + "IDOtro": { + "IDType": "", + "ID": "" + } + } + } +} diff --git a/l10n_es_aeat_verifactu/tests/test_10n_es_aeat_verifactu.py b/l10n_es_aeat_verifactu/tests/test_10n_es_aeat_verifactu.py index 15167871d9c..8a840709b7e 100644 --- a/l10n_es_aeat_verifactu/tests/test_10n_es_aeat_verifactu.py +++ b/l10n_es_aeat_verifactu/tests/test_10n_es_aeat_verifactu.py @@ -1,8 +1,11 @@ # Copyright 2024 Aures TIC - Almudena de La Puente # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) +import json from hashlib import sha256 +from odoo.modules.module import get_resource_path + from odoo.addons.l10n_es_aeat.tests.test_l10n_es_aeat_certificate import ( TestL10nEsAeatCertificateBase, ) @@ -11,10 +14,54 @@ ) -class TestL10nEsAeatSiiBase(TestL10nEsAeatModBase, TestL10nEsAeatCertificateBase): +class TestL10nEsAeatVerifactuBase(TestL10nEsAeatModBase, TestL10nEsAeatCertificateBase): @classmethod def setUpClass(cls): super().setUpClass() + cls.maxDiff = None + cls.fp_nacional = cls.env.ref(f"l10n_es.{cls.company.id}_fp_nacional") + cls.fp_registration_key_01 = cls.env.ref( + "l10n_es_aeat_verifactu.aeat_verifactu_registration_keys_01" + ) + cls.fp_nacional.verifactu_registration_key = cls.fp_registration_key_01 + cls.fp_recargo = cls.env.ref(f"l10n_es.{cls.company.id}_fp_recargo") + cls.fp_recargo.verifactu_registration_key = cls.fp_registration_key_01 + cls.partner = cls.env["res.partner"].create( + {"name": "Test partner", "vat": "89890001K"} + ) + cls.product = cls.env["product.product"].create({"name": "Test product"}) + cls.account_expense = cls.env.ref( + "l10n_es.%s_account_common_600" % cls.company.id + ) + cls.invoice = cls.env["account.move"].create( + { + "company_id": cls.company.id, + "partner_id": cls.partner.id, + "invoice_date": "2024-01-01", + "move_type": "out_invoice", + "invoice_line_ids": [ + ( + 0, + 0, + { + "product_id": cls.product.id, + "account_id": cls.account_expense.id, + "name": "Test line", + "price_unit": 100, + "quantity": 1, + }, + ) + ], + } + ) + cls.company.write( + { + "verifactu_enabled": True, + "verifactu_test": True, + "vat": "G87846952", + "tax_agency_id": cls.env.ref("l10n_es_aeat.aeat_tax_agency_spain"), + } + ) def test_verifactu_hash_code(self): # based on AEAT Verifactu documentation @@ -43,3 +90,109 @@ def test_verifactu_hash_code(self): sha_hash_code = sha256(verifactu_hash_string.encode("utf-8")) hash_code = sha_hash_code.hexdigest().upper() self.assertEqual(hash_code, expected_hash) + + def _create_and_test_invoice_verifactu_dict( + self, inv_type, lines, extra_vals, module=None + ): + vals = [] + tax_names = [] + for line in lines: + taxes = self.env["account.tax"] + for tax in line[1]: + if "." in tax: + xml_id = tax + else: + xml_id = "l10n_es.{}_account_tax_template_{}".format( + self.company.id, tax + ) + taxes += self.env.ref(xml_id) + tax_names.append(tax) + vals.append({"price_unit": line[0], "taxes": taxes}) + return self._compare_verifactu_dict( + "verifactu_{}_{}_dict.json".format(inv_type, "_".join(tax_names)), + inv_type, + vals, + extra_vals=extra_vals, + module=module, + ) + + def _compare_verifactu_dict( + self, json_file, inv_type, lines, extra_vals=None, module=None + ): + """Helper method for creating an invoice according arguments, and + comparing the expected verifactu dict with . + """ + module = module or "l10n_es_aeat_verifactu" + vals = { + "name": "TEST001", + "partner_id": self.partner.id, + "invoice_date": "2024-01-01", + "move_type": inv_type, + "invoice_line_ids": [], + } + for line in lines: + vals["invoice_line_ids"].append( + ( + 0, + 0, + { + "product_id": self.product.id, + "account_id": self.account_expense.id, + "name": "Test line", + "price_unit": line["price_unit"], + "quantity": 1, + "tax_ids": [(6, 0, line["taxes"].ids)], + }, + ) + ) + if extra_vals: + vals.update(extra_vals) + invoice = self.env["account.move"].create(vals) + result_dict = invoice._get_aeat_invoice_dict() + result_dict["RegistroAlta"].pop("FechaHoraHusoGenRegistro") + result_dict["RegistroAlta"].pop("TipoHuella") + result_dict["RegistroAlta"].pop("Huella") + path = get_resource_path(module, "tests/json", json_file) + if not path: + raise Exception("Incorrect JSON file: %s" % json_file) + with open(path, "r") as f: + expected_dict = json.loads(f.read()) + self.assertEqual(expected_dict, result_dict) + return invoice + + +class TestL10nEsAeatVerifactu(TestL10nEsAeatVerifactuBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_get_invoice_data(self): + mapping = [ + ( + "out_invoice", + [(100, ["s_iva10b"]), (200, ["s_iva21s"])], + { + "fiscal_position_id": self.fp_nacional.id, + "verifactu_registration_key": self.fp_registration_key_01.id, + }, + ), + ( + "out_refund", + [(100, ["s_iva10b"]), (100, ["s_iva10b"]), (200, ["s_iva21s"])], + { + "fiscal_position_id": self.fp_nacional.id, + "verifactu_registration_key": self.fp_registration_key_01.id, + }, + ), + ( + "out_invoice", + [(200, ["s_iva21s", "s_req52"])], + { + "fiscal_position_id": self.fp_recargo.id, + "verifactu_registration_key": self.fp_registration_key_01.id, + }, + ), + ] + for inv_type, lines, extra_vals in mapping: + self._create_and_test_invoice_verifactu_dict(inv_type, lines, extra_vals) + return diff --git a/l10n_es_aeat_verifactu/views/account_fiscal_position_view.xml b/l10n_es_aeat_verifactu/views/account_fiscal_position_view.xml index c04865cee7d..bdf506678ef 100644 --- a/l10n_es_aeat_verifactu/views/account_fiscal_position_view.xml +++ b/l10n_es_aeat_verifactu/views/account_fiscal_position_view.xml @@ -15,6 +15,11 @@ attrs="{'invisible': [('verifactu_enabled', '=', False)]}" > + + diff --git a/l10n_es_aeat_verifactu/views/account_journal_views.xml b/l10n_es_aeat_verifactu/views/account_journal_view.xml similarity index 100% rename from l10n_es_aeat_verifactu/views/account_journal_views.xml rename to l10n_es_aeat_verifactu/views/account_journal_view.xml diff --git a/l10n_es_aeat_verifactu/views/account_move_view.xml b/l10n_es_aeat_verifactu/views/account_move_view.xml index 30de813e7bf..410da260ac4 100644 --- a/l10n_es_aeat_verifactu/views/account_move_view.xml +++ b/l10n_es_aeat_verifactu/views/account_move_view.xml @@ -1,5 +1,6 @@ @@ -7,6 +8,22 @@ account.move + - - + + + + + + @@ -31,6 +52,7 @@ + - + diff --git a/l10n_es_aeat_verifactu/views/aeat_verifactu_map_lines_view.xml b/l10n_es_aeat_verifactu/views/aeat_verifactu_map_lines_view.xml new file mode 100644 index 00000000000..c8c9e219cec --- /dev/null +++ b/l10n_es_aeat_verifactu/views/aeat_verifactu_map_lines_view.xml @@ -0,0 +1,17 @@ + + + + + aeat.verifactu.map.lines.view.tree + aeat.verifactu.map.lines + + + + + + + + + + diff --git a/l10n_es_aeat_verifactu/views/aeat_verifactu_map_view.xml b/l10n_es_aeat_verifactu/views/aeat_verifactu_map_view.xml new file mode 100644 index 00000000000..f9e8733b012 --- /dev/null +++ b/l10n_es_aeat_verifactu/views/aeat_verifactu_map_view.xml @@ -0,0 +1,55 @@ + + + + + aeat.verifactu.map.view.tree + aeat.verifactu.map + + + + + + + + + + aeat.verifactu.map.view.form + aeat.verifactu.map + +
+ + + + + + + + + + + + + + +
+
+
+ + Aeat Verifactu Map + aeat.verifactu.map + tree,form + + + +
diff --git a/l10n_es_aeat_verifactu/views/aeat_verifactu_registration_keys_view.xml b/l10n_es_aeat_verifactu/views/aeat_verifactu_registration_keys_view.xml new file mode 100644 index 00000000000..708321561a2 --- /dev/null +++ b/l10n_es_aeat_verifactu/views/aeat_verifactu_registration_keys_view.xml @@ -0,0 +1,45 @@ + + + + + aeat.verifactu.registration.keys.view.tree + aeat.verifactu.registration.keys + + + + + + + + + + aeat.verifactu.registration.keys.view.search + aeat.verifactu.registration.keys + + + + + + + + + + + + + AEAT Verifactu Registration Keys + aeat.verifactu.registration.keys + tree,form + + + diff --git a/l10n_es_aeat_verifactu/views/res_company_view.xml b/l10n_es_aeat_verifactu/views/res_company_view.xml index b4cb7ee1e6f..24a8acd59a5 100644 --- a/l10n_es_aeat_verifactu/views/res_company_view.xml +++ b/l10n_es_aeat_verifactu/views/res_company_view.xml @@ -21,6 +21,11 @@
+ + + + +
diff --git a/l10n_es_aeat_verifactu/wizards/__init__.py b/l10n_es_aeat_verifactu/wizards/__init__.py new file mode 100644 index 00000000000..715d1bd6df0 --- /dev/null +++ b/l10n_es_aeat_verifactu/wizards/__init__.py @@ -0,0 +1 @@ +from . import account_move_reversal diff --git a/l10n_es_aeat_verifactu/wizards/account_move_reversal.py b/l10n_es_aeat_verifactu/wizards/account_move_reversal.py new file mode 100644 index 00000000000..6733d138318 --- /dev/null +++ b/l10n_es_aeat_verifactu/wizards/account_move_reversal.py @@ -0,0 +1,16 @@ +# Copyright 2024 Aures TIC - Almudena de La Puente +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +from odoo import models + + +class AccountMoveReversal(models.TransientModel): + _inherit = "account.move.reversal" + + def reverse_moves(self): + res = super().reverse_moves() + self.move_ids.filtered(lambda mov: mov.move_type == "out_invoice").mapped( + "reversal_move_id" + ).write({"verifactu_refund_type": "I"}) + return res