diff --git a/l10n_es_aeat_verifactu/README.rst b/l10n_es_aeat_verifactu/README.rst new file mode 100644 index 00000000000..6f49a7d8655 --- /dev/null +++ b/l10n_es_aeat_verifactu/README.rst @@ -0,0 +1,151 @@ +======================= +Comunicación Veri*FACTU +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:f266e60912fa651e7b5f2be5fde444a4cc9a53f259d83ff07ad36040c96713d7 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fl10n--spain-lightgray.png?logo=github + :target: https://github.com/OCA/l10n-spain/tree/16.0/l10n_es_aeat_verifactu + :alt: OCA/l10n-spain +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/l10n-spain-16-0/l10n-spain-16-0-l10n_es_aeat_verifactu + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/l10n-spain&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Módulo para la presentación inmediata de la facturación. + +**Table of contents** + +.. 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-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 +=========== + +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 +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Aures Tic +* ForgeFlow + +Contributors +~~~~~~~~~~~~ + +* Jose Zambudio +* Almudena de La Puente +* Laura Cazorla +* Andreu Orensanz +* Iván Antón +* Luis J. Salvatierra + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/l10n-spain `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/l10n_es_aeat_verifactu/__init__.py b/l10n_es_aeat_verifactu/__init__.py new file mode 100644 index 00000000000..aee8895e7a3 --- /dev/null +++ b/l10n_es_aeat_verifactu/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/l10n_es_aeat_verifactu/__manifest__.py b/l10n_es_aeat_verifactu/__manifest__.py new file mode 100644 index 00000000000..018994aad40 --- /dev/null +++ b/l10n_es_aeat_verifactu/__manifest__.py @@ -0,0 +1,39 @@ +# 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). + +{ + "name": "Comunicación Veri*FACTU", + "version": "16.0.1.0.0", + "category": "Accounting & Finance", + "website": "https://github.com/OCA/l10n-spain", + "author": "Aures Tic, ForgeFlow," "Odoo Community Association (OCA)", + "license": "AGPL-3", + "application": False, + "installable": True, + "external_dependencies": {"python": ["zeep", "requests"]}, + "depends": [ + "l10n_es", + "l10n_es_aeat", + "account_invoice_refund_link", + "queue_job", + "account", + ], + "data": [ + "data/aeat_verifactu_tax_agency_data.xml", + "data/aeat_verifactu_registration_keys.xml", + "data/aeat_verifactu_map_data.xml", + "data/aeat_verifactu_queue_job.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_view.xml", + "views/aeat_verifactu_map_view.xml", + "views/aeat_verifactu_map_lines_view.xml", + "views/aeat_verifactu_registration_keys_view.xml", + "views/report_invoice.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_queue_job.xml b/l10n_es_aeat_verifactu/data/aeat_verifactu_queue_job.xml new file mode 100644 index 00000000000..aae2154f45f --- /dev/null +++ b/l10n_es_aeat_verifactu/data/aeat_verifactu_queue_job.xml @@ -0,0 +1,12 @@ + + + + invoice_validate_verifactu + + + + + confirm_verifactu_one_document + + + 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_verifactu_tax_agency_data.xml b/l10n_es_aeat_verifactu/data/aeat_verifactu_tax_agency_data.xml new file mode 100644 index 00000000000..c641d5df822 --- /dev/null +++ b/l10n_es_aeat_verifactu/data/aeat_verifactu_tax_agency_data.xml @@ -0,0 +1,19 @@ + + + + + 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 + https://www2.agenciatributaria.gob.es/wlpl/TIKE-CONT/ValidarQR + https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR + + diff --git a/l10n_es_aeat_verifactu/data/neutralize.sql b/l10n_es_aeat_verifactu/data/neutralize.sql new file mode 100644 index 00000000000..18ac80f93b7 --- /dev/null +++ b/l10n_es_aeat_verifactu/data/neutralize.sql @@ -0,0 +1,2 @@ +-- 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 new file mode 100644 index 00000000000..c202ad7fd47 --- /dev/null +++ b/l10n_es_aeat_verifactu/models/__init__.py @@ -0,0 +1,10 @@ +from . import account_journal +from . import res_company +from . import verifactu_mixin +from . import account_move +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 new file mode 100644 index 00000000000..da7df1d3cc5 --- /dev/null +++ b/l10n_es_aeat_verifactu/models/account_fiscal_position.py @@ -0,0 +1,38 @@ +# 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 + + +class AccountFiscalPosition(models.Model): + _inherit = "account.fiscal.position" + + verifactu_enabled = fields.Boolean( + 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 new file mode 100644 index 00000000000..0c5ae479fa9 --- /dev/null +++ b/l10n_es_aeat_verifactu/models/account_journal.py @@ -0,0 +1,78 @@ +# 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 + + +class AccountJournal(models.Model): + _inherit = "account.journal" + + # TODEL? + # no vamos autilizar el hash de odoo porque la estructura no nos sirve + # para verifactu, por lo que este código no nos terminaría de valer. + # De momento lo dejamos hasta saber cómo vamos a controlar + # el tema de la factura anterior enviada a verifactu para el cálculo + # del hash, y el control de modificaciones en las facturas ya enviadas. + restrict_mode_hash_table = fields.Boolean( + compute="_compute_restrict_mode_hash_table", + store=True, + readonly=False, + ) + + restrict_mode_hash_table_readonly = fields.Boolean( + store=True, + compute="_compute_restrict_mode_hash_table", + ) + + @api.depends( + "company_id", "company_id.verifactu_enabled", "company_id.country_code", "type" + ) + def _compute_restrict_mode_hash_table(self): + for record in self: + record.restrict_mode_hash_table_readonly = False + if ( + record.company_id.verifactu_enabled + and record.company_id.country_code == "ES" + and record.type == "sale" + ): + record.write( + { + "restrict_mode_hash_table": True, + "restrict_mode_hash_table_readonly": True, + } + ) + + @api.model + def check_hash_modification(self, country_code, journal_type, verifactu_enabled): + if country_code == "ES" and journal_type == "sale" and verifactu_enabled: + raise ValidationError( + _("You can't have a sale journal in Spain with veri*FACTU enabled.") + ) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if ( + "restrict_mode_hash_table" in vals + and not vals["restrict_mode_hash_table"] + ): + company = self.env["res.company"].browse(vals.get("company_id")) + self.check_hash_modification( + company.country_code, vals.get("type"), company.verifactu_enabled + ) + return super().create(vals_list) + + def write(self, vals): + if "restrict_mode_hash_table" in vals and not vals["restrict_mode_hash_table"]: + for record in self: + new_company_id = vals.get("company_id", record.company_id.id) + new_company = self.env["res.company"].browse(new_company_id) + new_type = vals.get("type", record.type) + new_country_code = new_company.country_code + new_verifactu_enabled = new_company.verifactu_enabled + record.check_hash_modification( + new_country_code, new_type, new_verifactu_enabled + ) + return super().write(vals) diff --git a/l10n_es_aeat_verifactu/models/account_move.py b/l10n_es_aeat_verifactu/models/account_move.py new file mode 100644 index 00000000000..e1e1efe0a4a --- /dev/null +++ b/l10n_es_aeat_verifactu/models/account_move.py @@ -0,0 +1,483 @@ +# Copyright 2024 Aures TIC - Almudena de La Puente +# Copyright 2024 Aures Tic - Jose Zambudio +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import logging +from collections import OrderedDict +from datetime import datetime +from hashlib import sha256 +from time import sleep + +import pytz +from psycopg2 import OperationalError + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools import config + +_logger = logging.getLogger(__name__) + +VERIFACTU_VALID_INVOICE_STATES = ["posted"] +# TODO: review retry strategy +SEND_TO_VERIFACTU_MAX_RETRIES = 5 + + +class AccountMove(models.Model): + _name = "account.move" + _inherit = ["account.move", "verifactu.mixin"] + + verifactu_refund_specific_invoice_type = fields.Selection( + selection=[ + ( + "R1", + _("FACTURA RECTIFICATIVA (Art 80.1 y 80.2 y error fundado en derecho)"), + ), + ("R2", _("FACTURA RECTIFICATIVA (Art. 80.3)")), + ("R3", _("FACTURA RECTIFICATIVA (Art. 80.4)")), + ("R4", _("FACTURA RECTIFICATIVA (Resto)")), + ("R5", _("FACTURA RECTIFICATIVA EN FACTURAS SIMPLIFICADAS")), + ], + 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.", + ) + verifactu_registration_date = fields.Datetime() + verifactu_previous_invoice_id = fields.Many2one( + string="Last veri*FACTU Invoice sent", + comodel_name="account.move", + copy=False, + ) + verifactu_invoice_jobs_ids = fields.Many2many( + comodel_name="queue.job", + column1="invoice_id", + column2="job_id", + relation="account_move_verifactu_queue_job_rel", + string="Connector Jobs", + copy=False, + ) + + @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", + "company_id.verifactu_enabled", + "move_type", + "fiscal_position_id", + "fiscal_position_id.aeat_active", + ) + def _compute_verifactu_enabled(self): + """Compute if the invoice is enabled for the veri*FACTU""" + for invoice in self: + if invoice.company_id.verifactu_enabled and invoice.is_invoice(): + invoice.verifactu_enabled = ( + invoice.fiscal_position_id + and invoice.fiscal_position_id.aeat_active + ) or not invoice.fiscal_position_id + 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_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.invoice_date + + def _aeat_get_partner(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.commercial_partner_id + + def _get_document_fiscal_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.date + + def _get_mapping_key(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.move_type + + def _get_valid_document_states(self): + return VERIFACTU_VALID_INVOICE_STATES + + def _get_document_serial_number(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 + """ + serial_number = (self.name or "")[0:60] + if self.thirdparty_invoice: + serial_number = self.thirdparty_number[0:60] + return serial_number + + def _get_verifactu_issuer(self): + return self.company_id.partner_id._parse_aeat_vat_info()[2] + + def _get_verifactu_amount_tax(self): + return self.amount_tax_signed + + def _get_verifactu_amount_total(self): + return self.amount_total_signed + + def _get_verifactu_previous_hash(self): + return self.verifactu_previous_invoice_id.verifactu_hash + + def _get_verifactu_registration_date(self): + # Date format must be ISO 8601 + return ( + pytz.utc.localize(self.verifactu_registration_date) + .astimezone() + .isoformat(timespec="seconds") + ) + + def _get_verifactu_hash_string(self): + """Gets the verifactu hash string""" + if ( + not self.verifactu_enabled + or self.state == "draft" + or self.move_type not in ("out_invoice", "out_refund") + ): + return "" + issuerID = self._get_verifactu_issuer() + serialNumber = self._get_document_serial_number() + expeditionDate = self._change_date_format(self._get_document_date()) + documentType = self._get_verifactu_document_type() + amountTax = round(self._get_verifactu_amount_tax(), 2) + amountTotal = round(self._get_verifactu_amount_total(), 2) + previousHash = self._get_verifactu_previous_hash() + registrationDate = self._get_verifactu_registration_date() + verifactu_hash_string = ( + f"IDEmisorFactura={issuerID}&" + f"NumSerieFactura={serialNumber}&" + f"FechaExpedicionFactura={expeditionDate}&" + f"TipoFactura={documentType}&" + f"CuotaTotal={amountTax}&" + f"ImporteTotal={amountTotal}&" + f"Huella={previousHash}&" + f"FechaHoraHusoGenRegistro={registrationDate}" + ) + return verifactu_hash_string + + def _get_verifactu_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 _set_chaining_invoice(self): + """Retry handling should be done on caller method when OperationalError""" + prev_inv = False + try: + self.company_id.flush_model(["verifactu_last_invoice_id"]) + self._cr.execute( + "SELECT verifactu_last_invoice_id FROM" + " res_company WHERE id = %s FOR UPDATE NOWAIT", + [self.company_id.id], + ) + result = self._cr.fetchone() + prev_inv = self.env["account.move"].browse(result[0]) if result else False + if prev_inv: + self.verifactu_previous_invoice_id = prev_inv + self._cr.execute( + "UPDATE res_company SET " + "verifactu_last_invoice_id = %s WHERE id = %s", + (self.id, self.company_id.id), + ) + self.company_id.invalidate_recordset(["verifactu_last_invoice_id"]) + except OperationalError: + _logger.error( + "VERI*FACTU: Could not obtain lock for company %s and invoice %s", + self.company_id.id, + self.id, + ) + raise + return prev_inv + + def _get_chaining_invoice_dict(self): + if self.verifactu_previous_invoice_id: + prev_inv = self.verifactu_previous_invoice_id + return { + "RegistroAnterior": { + "IDEmisorFactura": prev_inv._get_verifactu_issuer(), + "NumSerieFactura": prev_inv._get_document_serial_number(), + "FechaExpedicionFactura": prev_inv._change_date_format( + prev_inv._get_document_date() + ), + "Huella": prev_inv.verifactu_hash, + } + } + 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_verifactu_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_verifactu_taxes_map(["S1"], document_date) + taxes_S2 = self._get_verifactu_taxes_map(["S2"], document_date) + taxes_N1 = self._get_verifactu_taxes_map(["N1"], document_date) + taxes_N2 = self._get_verifactu_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._get_verifactu_amount_tax(), + self._get_verifactu_amount_total(), + ) + + 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 _get_verifactu_qr_values(self): + """Get the QR values for the verifactu""" + self.ensure_one() + company_vat = self.company_id.partner_id._parse_aeat_vat_info()[2] + return OrderedDict( + [ + ("nif", company_vat), + ("numserie", self.name), + ("fecha", self.invoice_date.strftime("%d-%m-%Y")), + ("importe", self.amount_total), + ] + ) + + def _post(self, soft=True): + verifactu_reg_date = datetime.now() + self.write({"verifactu_registration_date": verifactu_reg_date}) + res = super()._post(soft=soft) + # TODO: review retry strategy + verifactu_invoices = self.filtered( + lambda move: self._is_verifactu_invoice(move) + ) + for record in verifactu_invoices: + for attempt in range(SEND_TO_VERIFACTU_MAX_RETRIES): + try: + record._set_chaining_invoice() + break + except OperationalError as oe: + if attempt == SEND_TO_VERIFACTU_MAX_RETRIES - 1: + raise OperationalError( + "Failed to chain invoice %s for Verifactu", + record.name, + ) from oe + else: + sleep(1) # Wait 1 second before next try + verifactu_hash_values = record._get_verifactu_hash_string() + record.verifactu_hash_string = verifactu_hash_values + hash_string = sha256(verifactu_hash_values.encode("utf-8")) + record.verifactu_hash = hash_string.hexdigest().upper() + + for invoice in self: + if not self._should_send_to_verifactu(invoice): + continue + invoice.send_verifactu() + return res + + def _is_verifactu_invoice(self, invoice): + return invoice.exists() and invoice.is_invoice() and invoice.verifactu_enabled + + def _should_send_to_verifactu(self, invoice): + return ( + self._is_verifactu_invoice(invoice) + and not config["test_enable"] + and invoice.state in VERIFACTU_VALID_INVOICE_STATES + ) + + def cancel_verifactu(self): + raise NotImplementedError + + def _get_verifactu_jobs_field_name(self): + return "verifactu_invoice_jobs_ids" diff --git a/l10n_es_aeat_verifactu/models/aeat_tax_agency.py b/l10n_es_aeat_verifactu/models/aeat_tax_agency.py new file mode 100644 index 00000000000..dbf95e6bcc1 --- /dev/null +++ b/l10n_es_aeat_verifactu/models/aeat_tax_agency.py @@ -0,0 +1,39 @@ +# 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 + +VERIFACTU_WDSL_MAPPING = { + "out_invoice": "verifactu_wsdl_out", + "out_refund": "verifactu_wsdl_out", +} +VERIFACTU_PORT_NAME_MAPPING = { + "out_invoice": "SistemaVerifactu", + "out_refund": "SistemaVerifactu", +} + + +class AeatTaxAgency(models.Model): + _inherit = "aeat.tax.agency" + + verifactu_wsdl_out = fields.Char(string="SuministroInformacion WSDL") + verifactu_wsdl_out_test_address = fields.Char( + string="SuministroInformacion Test Address" + ) + verifactu_qr_base_url = fields.Char(string="QR Base URL") + verifactu_qr_base_url_test_address = fields.Char(string="QR Base URL Test") + + def _connect_params_verifactu(self, mapping_key, company): + self.ensure_one() + wsdl_field = VERIFACTU_WDSL_MAPPING[mapping_key] + wsdl_test_field = wsdl_field + "_test_address" + 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: + port_name += "Pruebas" + return { + "wsdl": getattr(self, wsdl_field), + "address": address, + "port_name": port_name, + } 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 new file mode 100644 index 00000000000..58d2615c514 --- /dev/null +++ b/l10n_es_aeat_verifactu/models/res_company.py @@ -0,0 +1,57 @@ +# Copyright 2024 Jose Zambudio +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import datetime, timedelta + +import pytz + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + 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", + ) + verifactu_last_invoice_id = fields.Many2one( + string="Last veri*FACTU Invoice sent", + comodel_name="account.move", + copy=False, + ) + use_connector_verifactu = fields.Boolean( + help="Check it to use connector instead of sending the invoice " + "directly when it's validated", + ) + send_mode_verifactu = fields.Selection( + selection=[ + ("auto", "On validate"), + ("fixed", "At fixed time"), + ("delayed", "With delay"), + ], + default="auto", + ) + sent_time_verifactu = fields.Float() + delay_time_verifactu = fields.Float() + + def _get_verifactu_eta(self): + if self.send_mode_verifactu == "fixed": + tz = self.env.context.get("tz", self.env.user.partner_id.tz) + offset = datetime.now(pytz.timezone(tz)).strftime("%z") if tz else "+00" + hour_diff = int(offset[:3]) + hour, minute = divmod(self.sent_time_verifactu * 60, 60) + hour = int(hour - hour_diff) + minute = int(minute) + now = datetime.now() + if now.hour > hour or (now.hour == hour and now.minute > minute): + now += timedelta(days=1) + now = now.replace(hour=hour, minute=minute) + return now + elif self.send_mode_verifactu == "delayed": + return datetime.now() + timedelta(seconds=self.delay_time_verifactu * 3600) + else: + return None diff --git a/l10n_es_aeat_verifactu/models/res_partner.py b/l10n_es_aeat_verifactu/models/res_partner.py new file mode 100644 index 00000000000..59c82c333d1 --- /dev/null +++ b/l10n_es_aeat_verifactu/models/res_partner.py @@ -0,0 +1,27 @@ +# Copyright 2024 Aures TIC - Jose Zambudio +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + verifactu_enabled = fields.Boolean( + compute="_compute_aeat_sending_enabled", + ) + + @api.depends("company_id") + def _compute_aeat_sending_enabled(self): + res = super()._compute_aeat_sending_enabled() + verifactu_enabled = any(self.env.companies.mapped("verifactu_enabled")) + for partner in self: + verifactu_enabled = ( + partner.company_id.verifactu_enabled + if partner.company_id + else verifactu_enabled + ) + partner.verifactu_enabled = verifactu_enabled + if verifactu_enabled: + partner.aeat_sending_enabled = True + return res diff --git a/l10n_es_aeat_verifactu/models/verifactu_mixin.py b/l10n_es_aeat_verifactu/models/verifactu_mixin.py new file mode 100644 index 00000000000..8c8a4e8ad44 --- /dev/null +++ b/l10n_es_aeat_verifactu/models/verifactu_mixin.py @@ -0,0 +1,496 @@ +# 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). + +import base64 +import io +import json +import logging +from urllib.parse import urlencode + +from requests import Session + +from odoo import _, api, fields, models +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 + +_logger = logging.getLogger(__name__) + +try: + import qrcode +except (ImportError, IOError) as err: + qrcode = None + _logger.error(err) + +########################################### +# 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 _connect_verifactu +# de 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): + _name = "verifactu.mixin" + _inherit = "aeat.mixin" + _description = "Verifactu Mixin" + + verifactu_enabled = fields.Boolean( + string="Enable AEAT", + compute="_compute_verifactu_enabled", + ) + verifactu_hash_string = fields.Char(copy=False) + verifactu_hash = fields.Char(copy=False) + 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", + ) + verifactu_qr_url = fields.Char("URL", compute="_compute_verifactu_qr_url") + verifactu_qr = fields.Binary(string="QR", compute="_compute_verifactu_qr") + + def _compute_verifactu_enabled(self): + raise NotImplementedError + + def _compute_verifactu_macrodata(self): + for document in self: + document.verifactu_macrodata = ( + float_compare( + abs(document._get_verifactu_amount_total()), + VERIFACTU_MACRODATA_LIMIT, + precision_digits=2, + ) + >= 0 + ) + + def _compute_verifactu_qr_url(self): + """Returns the URL to be used in the QR code. A sample URL would be (urlencoded): + https://prewww2.aeat.es/wlpl/TIKECONT/ValidarQR?nif=89890001K&numserie=12345678%26G33&fecha=01-01-2024&importe=241.4 + """ # noqa: B950 + for record in self: + agency = self.env.ref("l10n_es_aeat.aeat_tax_agency_spain") + if record.company_id.verifactu_test: + qr_base_url = agency.verifactu_qr_base_url_test_address + else: + qr_base_url = agency.verifactu_qr_base_url + + qr_values = record._get_verifactu_qr_values() + + # Check all values are ASCII between 32 and 126 + for value in qr_values.values(): + try: + str(value).encode("ascii") + except UnicodeEncodeError as uee: + raise UserError( + _("QR URL value '{}' is not ASCII").format(value) + ) from uee + + # Build QR URL + qr_url = "{}?{}".format( + qr_base_url, + urlencode(qr_values, encoding="utf-8"), + ) + + record.verifactu_qr_url = qr_url + + def _compute_verifactu_qr(self): + # If qrcode module is not available, we can't generate QR codes + if not qrcode: + _logger.error("qrcode module is not available") + return + for record in self: + if record.state != "posted" or not record.verifactu_enabled: + record.verifactu_qr = False + continue + qr = qrcode.QRCode( + border=0, error_correction=qrcode.constants.ERROR_CORRECT_M + ) + qr.add_data(record.verifactu_qr_url) + qr.make() + img = qr.make_image() + with io.BytesIO() as temp: + img.save(temp, format="PNG") + record.verifactu_qr = base64.b64encode(temp.getvalue()) + + def _get_verifactu_qr_values(self): + raise NotImplementedError + + @api.model + def _get_verifactu_tax_keys(self): + return self.env["account.fiscal.position"]._get_verifactu_tax_keys() + + def _connect_params_aeat_verifactu(self, mapping_key): + self.ensure_one() + agency = self.company_id.tax_agency_id + if not agency: + # We use spanish agency by default to keep old behavior with + # ir.config parameters. In the future it might be good to reinforce + # to explicitly set a tax agency in the company by raising an error + # here. + agency = self.env.ref("l10n_es_aeat.aeat_tax_agency_spain") + return agency._connect_params_verifactu(mapping_key, self.company_id) + + def _get_aeat_header(self, tipo_comunicacion=False, cancellation=False): + """Builds VERIFACTU send header + + :param tipo_comunicacion String 'A0': new reg, 'A1': modification + :param cancellation Bool True when the communitacion es for document + cancellation + :return Dict with header data depending on cancellation + """ + self.ensure_one() + if not self.company_id.vat: + raise UserError( + _("No VAT configured for the company '{}'").format(self.company_id.name) + ) + header = { + "ObligadoEmision": { + "NombreRazon": self.company_id.name[0:120], + "NIF": self.company_id.partner_id._parse_aeat_vat_info()[2], + }, + } + return header + + def _get_verifactu_jobs_field_name(self): + raise NotImplementedError() + + def _get_verifactu_invoice_dict(self): + self.ensure_one() + inv_dict = {} + mapping_key = self._get_mapping_key() + if mapping_key in ["out_invoice", "out_refund"]: + inv_dict = self._get_verifactu_invoice_dict_out() + else: + raise NotImplementedError + round_by_keys( + inv_dict, + [ + "BaseImponibleOimporteNoSujeto", + "CuotaRepercutida", + "TipoRecargoEquivalencia", + "CuotaRecargoEquivalencia", + "CuotaTotal", + "ImporteTotal", + "BaseRectificada", + "CuotaRectificada", + ], + ) + return inv_dict + + def _get_verifactu_invoice_dict_out(self, cancel=False): + raise NotImplementedError + + def _get_verifactu_developer_dict(self): + """ + 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": "", + }, + } + + 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.""" + res = super()._aeat_check_exceptions() + if self.company_id.verifactu_enabled and not self.verifactu_enabled: + raise UserError(_("This invoice is not veri*FACTU enabled.")) + return res + + def _change_date_format(self, date): + datetimeobject = fields.Date.to_date(date) + new_date = datetimeobject.strftime(VERIFACTU_DATE_FORMAT) + return new_date + + def _get_verifactu_hash_string(self): + raise NotImplementedError + + 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 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): + queue_obj = self.env["queue.job"].sudo() + for record in self: + company = record.company_id + if not company.use_connector_verifactu: + record.confirm_verifactu_one_document() + else: + eta = company._get_verifactu_eta() + new_delay = ( + record.sudo() + .with_context(company_id=company.id) + .with_delay(eta=eta if not record.aeat_send_failed else False) + .confirm_verifactu_one_document() + ) + job = queue_obj.search([("uuid", "=", new_delay.uuid)], limit=1) + setattr( + record.sudo(), self._get_verifactu_jobs_field_name(), [(4, job.id)] + ) + + 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_verifactu_invoice_dict() + except Exception as fault: + raise ValidationError(fault) from fault + try: + mapping_key = document._get_mapping_key() + serv = document._connect_verifactu(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_verifactu(self, mapping_key): + # de momento no puedo el _connect_aeat 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_verifactu(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_verifactu_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", + "=", + "01", + ), + ] + 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 new file mode 100644 index 00000000000..f07af189f6a --- /dev/null +++ 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 new file mode 100644 index 00000000000..1835eb466bb --- /dev/null +++ b/l10n_es_aeat_verifactu/readme/CONTRIBUTORS.rst @@ -0,0 +1,6 @@ +* Jose Zambudio +* Almudena de La Puente +* Laura Cazorla +* Andreu Orensanz +* Iván Antón +* Luis J. Salvatierra diff --git a/l10n_es_aeat_verifactu/readme/DESCRIPTION.rst b/l10n_es_aeat_verifactu/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..68aea6818e9 --- /dev/null +++ b/l10n_es_aeat_verifactu/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Módulo para la presentación inmediata de la facturación. diff --git a/l10n_es_aeat_verifactu/readme/INSTALL.rst b/l10n_es_aeat_verifactu/readme/INSTALL.rst new file mode 100644 index 00000000000..0b8967682d7 --- /dev/null +++ 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 new file mode 100644 index 00000000000..19a5faee1a4 --- /dev/null +++ b/l10n_es_aeat_verifactu/readme/ROADMAP.rst @@ -0,0 +1,8 @@ + * 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/readme/USAGE.rst b/l10n_es_aeat_verifactu/readme/USAGE.rst new file mode 100644 index 00000000000..e69de29bb2d 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 new file mode 100644 index 00000000000..193e65c4729 --- /dev/null +++ b/l10n_es_aeat_verifactu/static/description/index.html @@ -0,0 +1,502 @@ + + + + + +Comunicación Veri*FACTU + + + +
+

Comunicación Veri*FACTU

+ + +

Beta License: AGPL-3 OCA/l10n-spain Translate me on Weblate Try me on Runboat

+

Módulo para la presentación inmediata de la facturación.

+

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

+
+
    +
  • 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

+

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 +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Aures Tic
  • +
  • ForgeFlow
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/l10n-spain project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/l10n_es_aeat_verifactu/tests/__init__.py b/l10n_es_aeat_verifactu/tests/__init__.py new file mode 100644 index 00000000000..c05db2d359d --- /dev/null +++ b/l10n_es_aeat_verifactu/tests/__init__.py @@ -0,0 +1 @@ +from . import test_l10n_es_aeat_verifactu 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..cdfca3337f3 --- /dev/null +++ b/l10n_es_aeat_verifactu/tests/json/verifactu_out_invoice_s_iva10b_s_iva21s_dict.json @@ -0,0 +1,56 @@ +{ + "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, + "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..99a406b7b14 --- /dev/null +++ b/l10n_es_aeat_verifactu/tests/json/verifactu_out_invoice_s_iva21s_s_req52_dict.json @@ -0,0 +1,50 @@ +{ + "RegistroAlta": { + "IDVersion": 1.0, + "IDFactura": { + "IDEmisorFactura": "G87846952", + "NumSerieFactura": "TEST003", + "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, + "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..e2067f9c26b --- /dev/null +++ b/l10n_es_aeat_verifactu/tests/json/verifactu_out_refund_s_iva10b_s_iva10b_s_iva21s_dict.json @@ -0,0 +1,58 @@ +{ + "RegistroAlta": { + "IDVersion": 1.0, + "IDFactura": { + "IDEmisorFactura": "G87846952", + "NumSerieFactura": "TEST002", + "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, + "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_l10n_es_aeat_verifactu.py b/l10n_es_aeat_verifactu/tests/test_l10n_es_aeat_verifactu.py new file mode 100644 index 00000000000..f227064b670 --- /dev/null +++ b/l10n_es_aeat_verifactu/tests/test_l10n_es_aeat_verifactu.py @@ -0,0 +1,394 @@ +# 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 urllib.parse import parse_qs, urlparse + +from psycopg2 import OperationalError + +from odoo.modules.module import get_resource_path + +from odoo.addons.l10n_es_aeat.tests.test_l10n_es_aeat_certificate import ( + TestL10nEsAeatCertificateBase, +) +from odoo.addons.l10n_es_aeat.tests.test_l10n_es_aeat_mod_base import ( + TestL10nEsAeatModBase, +) + + +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 + # https://www.agenciatributaria.es/static_files/AEAT_Desarrolladores/EEDD/IVA/VERI-FACTU/Veri-Factu_especificaciones_huella_hash_registros.pdf # noqa: B950 + expected_hash = ( + "3C464DAF61ACB827C65FDA19F352A4E3BDC2C640E9E9FC4CC058073F38F12F60" + ) + issuerID = "89890001K" + serialNumber = "12345678/G33" + expeditionDate = "01-01-2024" + documentType = "F1" + amountTax = "12.35" + amountTotal = "123.45" + previousHash = "" + registrationDate = "2024-01-01T19:20:30+01:00" + verifactu_hash_string = ( + f"IDEmisorFactura={issuerID}&" + f"NumSerieFactura={serialNumber}&" + f"FechaExpedicionFactura={expeditionDate}&" + f"TipoFactura={documentType}&" + f"CuotaTotal={amountTax}&" + f"ImporteTotal={amountTotal}&" + f"Huella={previousHash}&" + f"FechaHoraHusoGenRegistro={registrationDate}" + ) + 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, name, 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)), + name, + inv_type, + vals, + extra_vals=extra_vals, + module=module, + ) + + def _compare_verifactu_dict( + self, json_file, name, 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": name, + "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) + invoice.action_post() + result_dict = invoice._get_verifactu_invoice_dict() + result_dict["RegistroAlta"].pop("FechaHoraHusoGenRegistro") + result_dict["RegistroAlta"].pop("TipoHuella") + result_dict["RegistroAlta"].pop("Huella") + result_dict["RegistroAlta"].pop("Encadenamiento") + 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_verifactu_invoice_data(self): + mapping = [ + ( + "TEST001", + "out_invoice", + [(100, ["s_iva10b"]), (200, ["s_iva21s"])], + { + "fiscal_position_id": self.fp_nacional.id, + "verifactu_registration_key": self.fp_registration_key_01.id, + }, + ), + ( + "TEST002", + "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, + }, + ), + ( + "TEST003", + "out_invoice", + [(200, ["s_iva21s", "s_req52"])], + { + "fiscal_position_id": self.fp_recargo.id, + "verifactu_registration_key": self.fp_registration_key_01.id, + }, + ), + ] + for name, inv_type, lines, extra_vals in mapping: + self._create_and_test_invoice_verifactu_dict( + name, inv_type, lines, extra_vals + ) + return + + +class TestL10nEsAeatVerifactuQR(TestL10nEsAeatVerifactuBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def _get_required_qr_params(self): + """Helper to generate the required QR code parameters.""" + return { + "nif": self.invoice.company_id.partner_id._parse_aeat_vat_info()[2], + "numserie": self.invoice.name, + "fecha": self.invoice._change_date_format(self.invoice.invoice_date), + "importe": self.invoice.amount_total, + } + + def test_verifactu_qr_generation(self): + """ + Test the generation of the QR code image for the invoice. + """ + self.invoice.action_post() + qr_code = self.invoice.verifactu_qr + + self.assertTrue(qr_code, "QR code should be generated for the invoice.") + self.assertIsInstance(qr_code, bytes, "QR code should be in bytes format.") + + def test_verifactu_qr_url_format(self): + """ + Test the format of the generated QR URL to ensure it meets expected criteria. + """ + self.invoice.action_post() + qr_url = self.invoice.verifactu_qr_url + + self.assertTrue(qr_url, "QR URL should be generated for the invoice.") + + test_url = self.env.ref( + "l10n_es_aeat.aeat_tax_agency_spain" + ).verifactu_qr_base_url_test_address + self.assertTrue(test_url, "Test URL should not be empty.") + + parsed_url = urlparse(qr_url) + actual_params = parse_qs(parsed_url.query) + + expected_params = self._get_required_qr_params() + for key, expected_value in expected_params.items(): + self.assertIn( + key, actual_params, f"QR URL should contain the parameter: {key}" + ) + self.assertEqual( + actual_params[key][0], + str(expected_value), + f"QR URL parameter '{key}' should have value '{expected_value}', " + "got '{actual_params[key][0]}' instead.", + ) + + def test_verifactu_qr_code_generation_on_draft(self): + """ + Ensure that the QR code is not generated for invoices in draft state. + """ + qr_code = self.invoice.verifactu_qr + self.assertFalse(qr_code, "QR code should not be generated for draft invoices.") + + def test_verifactu_qr_code_after_update(self): + """ + Test that the QR code is regenerated if the invoice details are updated. + """ + self.invoice.action_post() + original_qr_code = self.invoice.verifactu_qr + + self.invoice.button_cancel() + + self.invoice.button_draft() + + self.invoice.write( + { + "invoice_line_ids": [ + ( + 0, + 0, + { + "product_id": self.product.id, + "account_id": self.account_expense.id, + "name": "Updated line", + "price_unit": 200, + "quantity": 1, + }, + ) + ] + } + ) + self.invoice.action_post() + + self.invoice.invalidate_model(["verifactu_qr_url", "verifactu_qr"]) + + updated_qr_code = self.invoice.verifactu_qr + + self.assertNotEqual( + original_qr_code, + updated_qr_code, + "QR code should be regenerated after invoice update.", + ) + + +class TestL10nEsAeatVerifactuChaining(TestL10nEsAeatVerifactuBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_get_chaining_invoice_dict_first_record(self): + """Test chaining dict when there's no previous invoice.""" + self.company.verifactu_last_invoice_id = False + self.invoice.action_post() + result = self.invoice._get_chaining_invoice_dict() + self.assertEqual( + result, + {"PrimerRegistro": "S"}, + "Should return first record indicator when no previous invoice exists", + ) + self.assertEqual( + self.company.verifactu_last_invoice_id.id, + self.invoice.id, + "Company's last invoice should be updated even for first record", + ) + + def test_get_chaining_invoice_dict_with_previous(self): + """Test chaining dict when there's a previous invoice.""" + self.invoice.action_post() + new_invoice = self.invoice.copy( + { + "invoice_date": "2024-01-01", + "name": "PREV001", + } + ) + new_invoice.action_post() + new_invoice._get_verifactu_invoice_dict() + self.assertEqual(self.company.verifactu_last_invoice_id, new_invoice) + + expected = { + "RegistroAnterior": { + "IDEmisorFactura": self.invoice._get_verifactu_issuer(), + "NumSerieFactura": self.invoice._get_document_serial_number(), + "FechaExpedicionFactura": self.invoice._change_date_format( + self.invoice._get_document_date() + ), + "Huella": self.invoice.verifactu_hash, + } + } + self.assertEqual( + new_invoice._get_chaining_invoice_dict(), + expected, + "Should return previous invoice data in correct format", + ) + self.assertEqual( + self.company.verifactu_last_invoice_id, + new_invoice, + "Should update company's last invoice reference", + ) + + def test_get_chaining_invoice_dict_operational_error(self): + """Test handling of OperationalError during chaining.""" + + def mock_execute(*args, **kwargs): + raise OperationalError("Test lock error") + + self.company.verifactu_last_invoice_id = False + self.invoice.action_post() + self.assertEqual(self.company.verifactu_last_invoice_id, self.invoice) + new_invoice = self.invoice.copy( + { + "invoice_date": "2024-01-01", + "name": "PREV001", + } + ) + old_execute = self.cr.execute + with self.assertRaises(OperationalError): + with self.cr.savepoint(): + self.cr.execute = mock_execute + new_invoice.action_post() + self.cr.execute = old_execute + self.assertEqual(new_invoice.state, "draft") + self.assertEqual( + self.company.verifactu_last_invoice_id, + self.invoice, + "Should not update company's last invoice reference on error", + ) diff --git a/l10n_es_aeat_verifactu/views/account_fiscal_position_view.xml b/l10n_es_aeat_verifactu/views/account_fiscal_position_view.xml new file mode 100644 index 00000000000..bdf506678ef --- /dev/null +++ b/l10n_es_aeat_verifactu/views/account_fiscal_position_view.xml @@ -0,0 +1,27 @@ + + + + + account.fiscal.position.form + account.fiscal.position + + + + + + + + + + + + + diff --git a/l10n_es_aeat_verifactu/views/account_journal_view.xml b/l10n_es_aeat_verifactu/views/account_journal_view.xml new file mode 100644 index 00000000000..6eed8c12365 --- /dev/null +++ b/l10n_es_aeat_verifactu/views/account_journal_view.xml @@ -0,0 +1,21 @@ + + + + account.journal.form - l10n_es_aeat_verifactu + account.journal + + + + + + + {'readonly': [('restrict_mode_hash_table_readonly', '=', True)]} + + + + diff --git a/l10n_es_aeat_verifactu/views/account_move_view.xml b/l10n_es_aeat_verifactu/views/account_move_view.xml new file mode 100644 index 00000000000..b6ccd11c1d3 --- /dev/null +++ b/l10n_es_aeat_verifactu/views/account_move_view.xml @@ -0,0 +1,96 @@ + + + + + account.invoice.verifactu.form + account.move + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/l10n_es_aeat_verifactu/views/aeat_tax_agency_view.xml b/l10n_es_aeat_verifactu/views/aeat_tax_agency_view.xml new file mode 100644 index 00000000000..2c0ca0e5416 --- /dev/null +++ b/l10n_es_aeat_verifactu/views/aeat_tax_agency_view.xml @@ -0,0 +1,31 @@ + + + + + aeat.tax.agency.form - l10n_es_aeat_verifactu_oca + aeat.tax.agency + + + + + + + + + + + + + + + + + + + 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/report_invoice.xml b/l10n_es_aeat_verifactu/views/report_invoice.xml new file mode 100644 index 00000000000..2f4b2d7516a --- /dev/null +++ b/l10n_es_aeat_verifactu/views/report_invoice.xml @@ -0,0 +1,26 @@ + + + + diff --git a/l10n_es_aeat_verifactu/views/res_company_view.xml b/l10n_es_aeat_verifactu/views/res_company_view.xml new file mode 100644 index 00000000000..de08f3ff486 --- /dev/null +++ b/l10n_es_aeat_verifactu/views/res_company_view.xml @@ -0,0 +1,50 @@ + + + + + res.company.verifactu.form + res.company + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/l10n_es_aeat_verifactu/views/res_partner_view.xml b/l10n_es_aeat_verifactu/views/res_partner_view.xml new file mode 100644 index 00000000000..f8d5d9987a9 --- /dev/null +++ b/l10n_es_aeat_verifactu/views/res_partner_view.xml @@ -0,0 +1,14 @@ + + + + + res.partner + + + + + + + + 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 diff --git a/l10n_es_aeat_verifactu_pos/README.rst b/l10n_es_aeat_verifactu_pos/README.rst new file mode 100644 index 00000000000..0cff04a7177 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/README.rst @@ -0,0 +1,86 @@ +============================ +Comunicación Veri*FACTU: TPV +============================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:81d39ea57c7a9f59a4d558c12bff79c2f5bfb4196a14da3cd3a42f8838f7b750 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fl10n--spain-lightgray.png?logo=github + :target: https://github.com/OCA/l10n-spain/tree/16.0/l10n_es_aeat_verifactu_pos + :alt: OCA/l10n-spain +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/l10n-spain-16-0/l10n-spain-16-0-l10n_es_aeat_verifactu_pos + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/l10n-spain&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Módulo para la presentación inmediata de la facturación desde TPV. + +**Table of contents** + +.. contents:: + :local: + +Known issues / Roadmap +====================== + +* Refactor `retry` strategy when database is locked trying to obtain the last verifactu invoice from PoS config +* Implement stopping mechanism to avoid sending more invoices to the AEAT when there is a problem with the chain +* Implement cancelling simplified and complete invoices from the PoS +* Multiple devices per PoS Config (l10n_es_pos_by_device) +* Invoicing already sent simplified invoice (PoS Order). Send anullment for the simplified and send a new one for the complete. + +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 +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Factor Libre S.L. + +Contributors +~~~~~~~~~~~~ + +* Luis J. Salvatierra + + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/l10n-spain `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/l10n_es_aeat_verifactu_pos/__init__.py b/l10n_es_aeat_verifactu_pos/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/l10n_es_aeat_verifactu_pos/__manifest__.py b/l10n_es_aeat_verifactu_pos/__manifest__.py new file mode 100644 index 00000000000..92106ca31b8 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/__manifest__.py @@ -0,0 +1,26 @@ +{ + "name": "Comunicación Veri*FACTU: TPV", + "version": "16.0.1.0.0", + "category": "Accounting & Finance", + "website": "https://github.com/OCA/l10n-spain", + "author": "Factor Libre S.L., Odoo Community Association (OCA)", + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": [ + "point_of_sale", + "l10n_es_pos", + "l10n_es_aeat_verifactu", + "pos_default_partner", + ], + "assets": { + "point_of_sale.assets": [ + "l10n_es_aeat_verifactu_pos/static/src/js/models.js", + "l10n_es_aeat_verifactu_pos/static/src/xml/OrderReceipt.xml", + "l10n_es_aeat_verifactu_pos/static/src/css/pos_receipts.css", + ], + }, + "data": [ + "views/pos_order_view.xml", + ], +} diff --git a/l10n_es_aeat_verifactu_pos/models/__init__.py b/l10n_es_aeat_verifactu_pos/models/__init__.py new file mode 100644 index 00000000000..d5de4ae842a --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/models/__init__.py @@ -0,0 +1,4 @@ +from . import pos_session +from . import pos_config +from . import pos_order +from . import account_move diff --git a/l10n_es_aeat_verifactu_pos/models/account_move.py b/l10n_es_aeat_verifactu_pos/models/account_move.py new file mode 100644 index 00000000000..24ff3e29c65 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/models/account_move.py @@ -0,0 +1,48 @@ +from odoo import _, exceptions, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + def _set_chaining_invoice(self): + """PoS Config has a different chain for VERI*FACTU""" + if self.pos_order_ids and len(self.pos_order_ids.ids) == 1: + # TODO: use cases + # * The Invoice is created from the PoS + # * The Invoice is created later after some time, in this case + # the PoS order (simplified invoice) is already in the chain + pos_order = self.pos_order_ids + if not pos_order.verifactu_previous_invoice_id: + return pos_order._set_chaining_invoice() + elif len(self.pos_order_ids) > 1: + # TODO: is possible to have multiple PoS orders for the same Invoice? + raise exceptions.UserError( + _("VERI*FACTU: multiple PoS Orders not supported") + ) + else: + return super()._set_chaining_invoice() + + def _get_chaining_invoice_dict(self): + """Get the chaining invoice dictionary for POS orders""" + if self.pos_order_ids and len(self.pos_order_ids.ids) == 1: + pos_order = self.pos_order_ids + return pos_order._get_chaining_invoice_dict() + elif len(self.pos_order_ids) > 1: + # TODO: is possible to have multiple PoS orders for the same Invoice? + raise exceptions.UserError( + _("VERI*FACTU: multiple PoS Orders not supported") + ) + else: + return super()._get_chaining_invoice_dict() + + def _get_verifactu_previous_hash(self): + if self.pos_order_ids and len(self.pos_order_ids.ids) == 1: + pos_order = self.pos_order_ids + return pos_order._get_verifactu_previous_hash() + elif len(self.pos_order_ids) > 1: + # TODO: is possible to have multiple PoS orders for the same Invoice? + raise exceptions.UserError( + _("VERI*FACTU: multiple PoS Orders not supported") + ) + else: + return super()._get_verifactu_previous_hash() diff --git a/l10n_es_aeat_verifactu_pos/models/pos_config.py b/l10n_es_aeat_verifactu_pos/models/pos_config.py new file mode 100644 index 00000000000..7b379f7c0a5 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/models/pos_config.py @@ -0,0 +1,28 @@ +from odoo import api, fields, models + + +class PosConfig(models.Model): + _inherit = "pos.config" + + verifactu_last_invoice_id = fields.Many2one( + string="Last veri*FACTU Order sent", + comodel_name="pos.order", + copy=False, + help="Last POS order sent to veri*FACTU system for chaining purposes", + ) + verifactu_base_url = fields.Char( + string="Verifactu Base URL", + compute="_compute_verifactu_base_url", + store=True, + help="Base URL for Verifactu QR code generation. Needed on PoS.", + ) + + @api.depends("company_id.verifactu_test") + def _compute_verifactu_base_url(self): + for record in self: + agency = self.env.ref("l10n_es_aeat.aeat_tax_agency_spain") + record.verifactu_base_url = ( + agency.verifactu_qr_base_url_test_address + if record.company_id.verifactu_test + else agency.verifactu_qr_base_url + ) diff --git a/l10n_es_aeat_verifactu_pos/models/pos_order.py b/l10n_es_aeat_verifactu_pos/models/pos_order.py new file mode 100644 index 00000000000..51b047c6df9 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/models/pos_order.py @@ -0,0 +1,431 @@ +import logging +from collections import OrderedDict +from hashlib import sha256 +from time import sleep + +import pytz +from psycopg2 import OperationalError + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools import config + +_logger = logging.getLogger(__name__) + +VERIFACTU_VALID_POS_STATES = [ + "paid", # paid is set on PoS order processing + "done", # done is set on PoS session validation (closing) +] + +# TODO: move to l10n_es_aeat_verifactu +SEND_TO_VERIFACTU_MAX_RETRIES = 5 + + +class PosOrder(models.Model): + _name = "pos.order" + _inherit = ["pos.order", "verifactu.mixin"] + + verifactu_previous_invoice_id = fields.Many2one( + string="Previous veri*FACTU Order sent", + comodel_name="pos.order", + copy=False, + ) + verifactu_pos_order_jobs_ids = fields.Many2many( + comodel_name="queue.job", + column1="pos_order_id", + column2="job_id", + relation="pos_order_verifactu_queue_job_rel", + string="Connector Jobs", + copy=False, + ) + + @api.depends("amount_total") + def _compute_verifactu_macrodata(self): + return super()._compute_verifactu_macrodata() + + @api.depends( + "company_id", + "company_id.verifactu_enabled", + "fiscal_position_id", + "fiscal_position_id.aeat_active", + ) + def _compute_verifactu_enabled(self): + """Compute if the POS order is enabled for the veri*FACTU""" + for order in self: + if order.company_id.verifactu_enabled: + order.verifactu_enabled = ( + order.fiscal_position_id and order.fiscal_position_id.aeat_active + ) or not order.fiscal_position_id + else: + order.verifactu_enabled = False + + @api.model + def _process_order(self, order, draft, existing_order): + pos_order_id = super()._process_order(order, draft, existing_order) + pos_order = self.env["pos.order"].browse(pos_order_id) + + if not self._is_verifactu_order(pos_order): + return pos_order_id + + # TODO: review retry strategy + # possible scenarios: multiple devices registering invoices + # from the same PoS Config + for attempt in range(SEND_TO_VERIFACTU_MAX_RETRIES): + try: + pos_order._set_chaining_invoice() + break + except OperationalError: + if attempt == SEND_TO_VERIFACTU_MAX_RETRIES - 1: + # TODO: should we have a stopping mechanism and avoid sending more + # invoices for this chain when it is no possible to obtain a lock + # on verifactu_last_invoice_id (pos.config)? + _logger.error( + "Failed to send order %s with ID %d to Verifactu after %d attempts", + pos_order.l10n_es_unique_id, + pos_order.id, + SEND_TO_VERIFACTU_MAX_RETRIES, + ) + raise + else: + sleep(1) # Wait 1 second before next try + verifactu_hash_values = pos_order._get_verifactu_hash_string() + pos_order.verifactu_hash_string = verifactu_hash_values + hash_string = sha256(verifactu_hash_values.encode("utf-8")) + pos_order.verifactu_hash = hash_string.hexdigest().upper() + + if self._should_send_to_verifactu(pos_order): + pos_order.send_verifactu() + + return pos_order_id + + def _is_verifactu_order(self, pos_order): + return ( + pos_order.exists() + and not pos_order.to_invoice + and pos_order.verifactu_enabled + ) + + def _should_send_to_verifactu(self, pos_order): + return ( + self._is_verifactu_order(pos_order) + and not config["test_enable"] + and pos_order.state in VERIFACTU_VALID_POS_STATES + ) + + def _get_verifactu_document_type(self): + return "F2" # Simplified invoice for POS orders + + def _get_verifactu_description(self): + return self.verifactu_description or self.company_id.verifactu_description + + def _get_document_date(self): + return self.date_order + + def _get_document_fiscal_date(self): + return self.date_order + + def _get_valid_document_states(self): + return VERIFACTU_VALID_POS_STATES + + def _get_document_serial_number(self): + return (self.l10n_es_unique_id or self.pos_reference)[0:60] + + def _get_mapping_key(self): + return "out_invoice" + + def _get_verifactu_issuer(self): + return self.company_id.partner_id._parse_aeat_vat_info()[2] + + def _get_verifactu_amount_tax(self): + return self.amount_tax + + def _get_verifactu_amount_total(self): + return self.amount_total + + def _get_verifactu_previous_hash(self): + previous_order = self.verifactu_previous_invoice_id + if not previous_order.is_invoiced: + return previous_order.verifactu_hash + else: + previous_inv = previous_order.account_move + return previous_inv.verifactu_hash + + def _get_verifactu_registration_date(self): + return ( + pytz.utc.localize(self.create_date) + .astimezone() + .isoformat(timespec="seconds") + ) + + def _get_verifactu_qr_values(self): + """Get the QR values for the verifactu""" + self.ensure_one() + return OrderedDict( + [ + ("nif", self._get_verifactu_issuer()), + ("numserie", self._get_document_serial_number()), + ( + "fecha", + self._change_date_format(self._get_document_fiscal_date()), + ), + ("importe", self._get_verifactu_amount_total()), + ] + ) + + def _get_verifactu_hash_string(self): + """Gets the verifactu hash string""" + if ( + not self.verifactu_enabled + or self.state not in VERIFACTU_VALID_POS_STATES + or self.is_invoiced + ): + return "" + issuerID = self._get_verifactu_issuer() + serialNumber = self._get_document_serial_number() + expeditionDate = self._change_date_format(self._get_document_date()) + documentType = self._get_verifactu_document_type() + amountTax = round(self._get_verifactu_amount_tax(), 2) + amountTotal = round(self._get_verifactu_amount_total(), 2) + previousHash = self._get_verifactu_previous_hash() + registrationDate = self._get_verifactu_registration_date() + verifactu_hash_string = ( + f"IDEmisorFactura={issuerID}&" + f"NumSerieFactura={serialNumber}&" + f"FechaExpedicionFactura={expeditionDate}&" + f"TipoFactura={documentType}&" + f"CuotaTotal={amountTax}&" + f"ImporteTotal={amountTotal}&" + f"Huella={previousHash}&" + f"FechaHoraHusoGenRegistro={registrationDate}" + ) + return verifactu_hash_string + + def _get_verifactu_invoice_dict_out(self, cancel=False): + """Build dict with data to send to AEAT WS for POS orders.""" + 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, + "DescripcionOperacion": self._get_verifactu_description(), + "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 _set_chaining_invoice(self): + """Set the chaining order""" + prev_order = False + try: + self.config_id.flush_model(["verifactu_last_invoice_id"]) + self._cr.execute( + "SELECT verifactu_last_invoice_id FROM" + " pos_config WHERE id = %s FOR UPDATE NOWAIT", + [self.config_id.id], + ) + result = self._cr.fetchone() + prev_order = self.env["pos.order"].browse(result[0]) if result else False + if prev_order and prev_order.exists(): + self.verifactu_previous_invoice_id = prev_order + self._cr.execute( + "UPDATE pos_config SET verifactu_last_invoice_id = %s WHERE id = %s", + (self.id, self.config_id.id), + ) + self.config_id.invalidate_recordset(["verifactu_last_invoice_id"]) + except OperationalError: + _logger.error( + "VERI*FACTU: Could not obtain lock for PoS Config %s " + "and order %s with ID %d", + self.config_id.id, + self.l10n_es_unique_id, + self.id, + ) + raise + return prev_order + + def _get_chaining_invoice_dict(self): + """Get the chaining invoice dictionary for POS orders""" + if self.verifactu_previous_invoice_id: + prev_order = self.verifactu_previous_invoice_id + return { + "RegistroAnterior": { + "IDEmisorFactura": prev_order._get_verifactu_issuer(), + "NumSerieFactura": prev_order._get_document_serial_number(), + "FechaExpedicionFactura": prev_order._change_date_format( + prev_order._get_document_date() + ), + "Huella": self._get_verifactu_previous_hash(), + } + } + return {"PrimerRegistro": "S"} + + def _get_verifactu_taxes_and_total(self): + """Get the tax breakdown for Verifactu from POS order lines. + + Returns: + tuple: (taxes_dict, amount_tax, amount_total) where: + - taxes_dict: Dictionary with tax breakdown + - amount_tax: Total tax amount + - amount_total: Total amount with taxes + """ + self.ensure_one() + taxes_dict = {} + taxes_dict.setdefault("DetalleDesglose", []) + + # Get tax lines from POS order + tax_lines = {} + for line in self.lines: + price = line.price_unit * (1 - (line.discount or 0.0) / 100.0) + taxes = line.tax_ids_after_fiscal_position.compute_all( + price, + self.pricelist_id.currency_id, + line.qty, + product=line.product_id, + partner=self.partner_id or False, + ) + + for tax_vals in taxes["taxes"]: + tax = self.env["account.tax"].browse(tax_vals["id"]) + if tax not in tax_lines: + tax_lines[tax] = { + "tax": tax, + "base": tax_vals["base"], + "amount": tax_vals["amount"], + } + else: + tax_lines[tax]["base"] += tax_vals["base"] + tax_lines[tax]["amount"] += tax_vals["amount"] + + # Get tax mappings + document_date = self._get_document_fiscal_date() + taxes_S1 = self._get_verifactu_taxes_map(["S1"], document_date) + taxes_S2 = self._get_verifactu_taxes_map(["S2"], document_date) + taxes_N1 = self._get_verifactu_taxes_map(["N1"], document_date) + taxes_N2 = self._get_verifactu_taxes_map(["N2"], document_date) + breakdown_taxes = taxes_S1 + taxes_S2 + taxes_N1 + taxes_N2 + + # Build tax breakdown + 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, + } + tax_dict.update(self._get_verifactu_tax_dict(tax_line, tax_lines)) + taxes_dict["DetalleDesglose"].append(tax_dict) + + return ( + taxes_dict, + self._get_verifactu_amount_tax(), + self._get_verifactu_amount_total(), + ) + + def _get_verifactu_tax_dict(self, tax_line, tax_lines): + """Get the Verifactu tax dictionary for the passed tax line. + + Args: + tax_line (dict): Tax line being analyzed + tax_lines (dict): Dictionary of processed taxes + + Returns: + dict: 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, + "CuotaRepercutida": 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. + + Args: + tax: Initial tax for searching RE linked tax + + Returns: + account.tax: REQ tax linked to provided tax + + Raises: + UserError: If there's a mismatch in RE taxes + """ + self.ensure_one() + document_date = self._get_document_fiscal_date() + taxes_req = self._get_verifactu_taxes_map(["RE"], document_date) + + re_lines = self.lines.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_operation_type(self, tax_line, taxes_S1, taxes_S2, taxes_N1, taxes_N2): + """Get the operation type for Verifactu based on tax configuration. + + Args: + tax_line (dict): Tax line info + taxes_S1: Taxes for type S1 + taxes_S2: Taxes for type S2 + taxes_N1: Taxes for type N1 + taxes_N2: Taxes for type N2 + + Returns: + str: Operation type code (S1, S2, N1, N2) + """ + 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 cancel_verifactu(self): + raise NotImplementedError + + def _get_verifactu_jobs_field_name(self): + return "verifactu_pos_order_jobs_ids" diff --git a/l10n_es_aeat_verifactu_pos/models/pos_session.py b/l10n_es_aeat_verifactu_pos/models/pos_session.py new file mode 100644 index 00000000000..f53d4829f43 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/models/pos_session.py @@ -0,0 +1,14 @@ +from odoo import models + + +class PosSession(models.Model): + _inherit = "pos.session" + + def _loader_params_res_company(self): + params = super()._loader_params_res_company() + params["search_params"]["fields"].extend( + [ + "verifactu_enabled", + ] + ) + return params diff --git a/l10n_es_aeat_verifactu_pos/readme/CONTRIBUTORS.rst b/l10n_es_aeat_verifactu_pos/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..0928ebdc901 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Luis J. Salvatierra + diff --git a/l10n_es_aeat_verifactu_pos/readme/DESCRIPTION.rst b/l10n_es_aeat_verifactu_pos/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..ca6fa68a854 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Módulo para la presentación inmediata de la facturación desde TPV. diff --git a/l10n_es_aeat_verifactu_pos/readme/ROADMAP.rst b/l10n_es_aeat_verifactu_pos/readme/ROADMAP.rst new file mode 100644 index 00000000000..cc0a2a92ded --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/readme/ROADMAP.rst @@ -0,0 +1,5 @@ +* Refactor `retry` strategy when database is locked trying to obtain the last verifactu invoice from PoS config +* Implement stopping mechanism to avoid sending more invoices to the AEAT when there is a problem with the chain +* Implement cancelling simplified and complete invoices from the PoS +* Multiple devices per PoS Config (l10n_es_pos_by_device) +* Invoicing already sent simplified invoice (PoS Order). Send anullment for the simplified and send a new one for the complete. diff --git a/l10n_es_aeat_verifactu_pos/static/description/index.html b/l10n_es_aeat_verifactu_pos/static/description/index.html new file mode 100644 index 00000000000..90b40eaa925 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/static/description/index.html @@ -0,0 +1,431 @@ + + + + + +Comunicación Veri*FACTU: TPV + + + +
+

Comunicación Veri*FACTU: TPV

+ + +

Beta License: AGPL-3 OCA/l10n-spain Translate me on Weblate Try me on Runboat

+

Módulo para la presentación inmediata de la facturación desde TPV.

+

Table of contents

+ +
+

Known issues / Roadmap

+
    +
  • Refactor retry strategy when database is locked trying to obtain the last verifactu invoice from PoS config
  • +
  • Implement stopping mechanism to avoid sending more invoices to the AEAT when there is a problem with the chain
  • +
  • Implement cancelling simplified and complete invoices from the PoS
  • +
  • Multiple devices per PoS Config (l10n_es_pos_by_device)
  • +
  • Invoicing already sent simplified invoice (PoS Order). Send anullment for the simplified and send a new one for the complete.
  • +
+
+
+

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 +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Factor Libre S.L.
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/l10n-spain project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/l10n_es_aeat_verifactu_pos/static/src/css/pos_receipts.css b/l10n_es_aeat_verifactu_pos/static/src/css/pos_receipts.css new file mode 100644 index 00000000000..55d0de67dda --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/static/src/css/pos_receipts.css @@ -0,0 +1,6 @@ +.pos-receipt .verifactu-qr { + display: block; + margin: 8px auto; + width: 40mm; + height: 40mm; +} diff --git a/l10n_es_aeat_verifactu_pos/static/src/js/models.js b/l10n_es_aeat_verifactu_pos/static/src/js/models.js new file mode 100644 index 00000000000..29f564f7fbd --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/static/src/js/models.js @@ -0,0 +1,60 @@ +odoo.define("l10n_es_aeat_verifactu_pos.models", function (require) { + "use strict"; + + const {Order} = require("point_of_sale.models"); + const Registries = require("point_of_sale.Registries"); + + const VerifactuOrder = (Order) => + class VerifactuOrder extends Order { + export_for_printing() { + const result = super.export_for_printing(...arguments); + result.verifactu_qr = + this.finalized && this._get_verifactu_qr_code_data(); + return result; + } + + _build_verifactu_qr_url() { + const baseUrl = this.pos.config.verifactu_base_url; + const company_partner = this.pos.db.get_partner_by_id( + this.pos.company.partner_id[0] + ); + const vatNumber = company_partner.aeat_identification_type + ? company_partner.aeat_identification + : (this.pos.company.vat || "").replace(/^ES/i, ""); + const date = this.validation_date || this.creation_date; + const formattedDate = moment(date).format("DD-MM-YYYY"); + const params = new URLSearchParams({ + nif: vatNumber, + numserie: this.l10n_es_unique_id, + fecha: formattedDate, + importe: this.get_total_with_tax(), + }); + return `${baseUrl}?${params.toString()}`; + } + + _get_verifactu_qr_code_data() { + const isEnabled = + this.pos.company.verifactu_enabled && + this.is_simplified_invoice && + (!this.fiscal_position || + (this.fiscal_position && this.fiscal_position.aeat_active)); + + if (isEnabled) { + const codeWriter = new window.ZXing.BrowserQRCodeSvgWriter(); + const address = this._build_verifactu_qr_url(); + const hints = new Map(); + hints.set( + window.ZXing.EncodeHintType.ERROR_CORRECTION, + window.ZXing.QRCodeDecoderErrorCorrectionLevel.M + ); + hints.set(window.ZXing.EncodeHintType.MARGIN, 0); // Minimize quiet zone + const qr_code_svg = new XMLSerializer().serializeToString( + codeWriter.write(address, 150, 150, hints) + ); + return "data:image/svg+xml;base64," + window.btoa(qr_code_svg); + } + return false; + } + }; + Registries.Model.extend(Order, VerifactuOrder); +}); diff --git a/l10n_es_aeat_verifactu_pos/static/src/xml/OrderReceipt.xml b/l10n_es_aeat_verifactu_pos/static/src/xml/OrderReceipt.xml new file mode 100644 index 00000000000..85d85c74388 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/static/src/xml/OrderReceipt.xml @@ -0,0 +1,23 @@ + + + + + +
+ QR Tributario: + + VERI*FACTU +
+
+
+
+
+
diff --git a/l10n_es_aeat_verifactu_pos/tests/__init__.py b/l10n_es_aeat_verifactu_pos/tests/__init__.py new file mode 100644 index 00000000000..a630e58ecc6 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/tests/__init__.py @@ -0,0 +1 @@ +from . import test_l10n_es_aeat_verifactu_pos diff --git a/l10n_es_aeat_verifactu_pos/tests/test_l10n_es_aeat_verifactu_pos.py b/l10n_es_aeat_verifactu_pos/tests/test_l10n_es_aeat_verifactu_pos.py new file mode 100644 index 00000000000..963a2fa0277 --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/tests/test_l10n_es_aeat_verifactu_pos.py @@ -0,0 +1,392 @@ +import uuid + +from psycopg2 import OperationalError + +from odoo import fields +from odoo.tests.common import tagged + +from odoo.addons.l10n_es_aeat_verifactu.tests.test_l10n_es_aeat_verifactu import ( + TestL10nEsAeatVerifactuBase, +) + + +@tagged("post_install", "-at_install") +class TestL10nEsAeatVerifactuPOS(TestL10nEsAeatVerifactuBase): + @classmethod + def copy_account(cls, account, default=None): + suffix_nb = 1 + while True: + new_code = "%s.%s" % (account.code, suffix_nb) + if account.search_count( + [("company_id", "=", account.company_id.id), ("code", "=", new_code)] + ): + suffix_nb += 1 + else: + return account.copy(default={**(default or {}), "code": new_code}) + + @classmethod + def setUpClass(cls): + super().setUpClass() + + sequence = cls.env["ir.sequence"].create( + { + "name": "POS Simplified Invoice", + "prefix": "SIM/", + "padding": 4, + } + ) + sale_journal = cls.env["account.journal"].create( + { + "name": "PoS Sale EUR", + "type": "sale", + "code": "POSE", + "company_id": cls.company.id, + "sequence": 12, + "currency_id": cls.env.ref("base.EUR").id, + } + ) + invoice_sale_journal = sale_journal.copy( + { + "name": "Invoice Sale EUR", + "code": "ISE", + } + ) + cls.pos_config = cls.env["pos.config"].create( + { + "name": "Test POS", + "company_id": cls.company.id, + "l10n_es_simplified_invoice_limit": 3000, + "l10n_es_simplified_invoice_sequence_id": sequence.id, + "journal_id": sale_journal.id, + "invoice_journal_id": invoice_sale_journal.id, + "iface_l10n_es_simplified_invoice": True, + "default_partner_id": cls.env["res.partner"] + .create( + { + "name": "Test simplified default customer", + "aeat_simplified_invoice": True, + } + ) + .id, + } + ) + cls.company.account_default_pos_receivable_account_id = cls.env[ + "account.account" + ].create( + { + "code": "X1012.POS", + "name": "Debtors - (POS)", + "reconcile": True, + "account_type": "asset_receivable", + } + ) + cls.pos_receivable_account = ( + cls.company.account_default_pos_receivable_account_id + ) + cls.pos_receivable_cash = cls.copy_account( + cls.company.account_default_pos_receivable_account_id, + {"name": "POS Receivable Cash"}, + ) + cls.pos_receivable_bank = cls.copy_account( + cls.company.account_default_pos_receivable_account_id, + {"name": "POS Receivable Bank"}, + ) + cls.outstanding_bank = cls.copy_account( + cls.company.account_journal_payment_debit_account_id, + {"name": "Outstanding Bank"}, + ) + cls.default_journal_cash = cls.env["account.journal"].search( + [("company_id", "=", cls.company.id), ("type", "=", "cash")], limit=1 + ) + cls.default_journal_bank = cls.env["account.journal"].search( + [("company_id", "=", cls.company.id), ("type", "=", "bank")], limit=1 + ) + cls.cash_pm1 = cls.env["pos.payment.method"].create( + { + "name": "Cash", + "journal_id": cls.default_journal_cash.id, + "receivable_account_id": cls.pos_receivable_cash.id, + "company_id": cls.env.company.id, + } + ) + cls.bank_pm1 = cls.env["pos.payment.method"].create( + { + "name": "Bank", + "journal_id": cls.default_journal_bank.id, + "receivable_account_id": cls.pos_receivable_bank.id, + "outstanding_account_id": cls.outstanding_bank.id, + "company_id": cls.env.company.id, + } + ) + cls.pos_config.write( + {"payment_method_ids": [(6, 0, (cls.cash_pm1 + cls.bank_pm1).ids)]} + ) + cls.pos_config.open_ui() + cls.pos_session = cls.pos_config.current_session_id + + cls.tax_21 = cls.env.ref( + f"l10n_es.{cls.company.id}_account_tax_template_s_iva21b" + ) + cls.tax_10 = cls.env.ref( + f"l10n_es.{cls.company.id}_account_tax_template_s_iva10b" + ) + + def _create_ui_order_data(self, amount=100, simplified=True): + """Helper to create UI order data""" + uid = str(uuid.uuid4()) + return { + "data": { + "amount_paid": amount * 1.21, + "amount_total": amount * 1.21, + "amount_tax": amount * 0.21, + "amount_return": 0, + "creation_date": fields.Datetime.to_string(fields.Datetime.now()), + "fiscal_position_id": False, + "pricelist_id": self.pos_config.available_pricelist_ids[0].id, + "lines": [ + [ + 0, + 0, + { + "product_id": self.product.id, + "price_unit": amount, + "qty": 1, + "tax_ids": [[6, False, self.tax_21.ids]], + "price_subtotal": amount, + "price_subtotal_incl": amount * 1.21, + }, + ] + ], + "name": "Order 0001", + "pos_session_id": self.pos_session.id, + "sequence_number": 2, + "partner_id": self.partner.id, + "l10n_es_unique_id": simplified and "SIM/0001" or False, + "uid": uid, + "user_id": self.env.uid, + "statement_ids": [ + ( + 0, + 0, + { + "amount": amount * 1.21, + "name": fields.Datetime.now(), + "payment_method_id": self.cash_pm1.id, + }, + ) + ], + }, + "id": uid, + "to_invoice": not simplified, + } + + def test_simplified_invoice_verifactu_flow(self): + orders_data = [self._create_ui_order_data()] + order_ids = self.env["pos.order"].create_from_ui(orders_data) + order = self.env["pos.order"].browse(order_ids[0]["id"]) + + self.assertTrue( + order.is_l10n_es_simplified_invoice, + "Order should be marked as simplified invoice", + ) + self.assertEqual( + order.l10n_es_unique_id, + "SIM/0001", + "Order should have correct simplified invoice number", + ) + + self.assertTrue( + order.verifactu_enabled, + "Verifactu should be enabled for simplified invoices", + ) + self.assertEqual( + order._get_verifactu_document_type(), + "F2", + "Document type should be F2 for simplified invoices", + ) + self.assertEqual( + order._get_document_serial_number(), + "SIM/0001", + "Serial number should match simplified invoice number", + ) + + def test_verifactu_hash_string(self): + """Test the generation of Verifactu hash string for POS orders""" + orders_data = [self._create_ui_order_data()] + order_ids = self.env["pos.order"].create_from_ui(orders_data) + order = self.env["pos.order"].browse(order_ids[0]["id"]) + + hash_string = order._get_verifactu_hash_string() + components = dict(item.split("=") for item in hash_string.split("&")) + + self.assertEqual( + components["IDEmisorFactura"], + self.company.partner_id._parse_aeat_vat_info()[2], + "Incorrect issuer ID", + ) + self.assertEqual( + components["NumSerieFactura"], + "SIM/0001", + "Incorrect serial number", + ) + self.assertEqual( + components["TipoFactura"], + "F2", + "Incorrect document type for POS order", + ) + self.assertEqual( + float(components["CuotaTotal"]), + 21.0, + "Incorrect tax amount", + ) + self.assertEqual( + float(components["ImporteTotal"]), + 121.0, + "Incorrect total amount", + ) + + def test_verifactu_invoice_dict_out(self): + """Test the generation of outgoing invoice dictionary for POS orders""" + orders_data = [self._create_ui_order_data()] + order_ids = self.env["pos.order"].create_from_ui(orders_data) + order = self.env["pos.order"].browse(order_ids[0]["id"]) + + result = order._get_verifactu_invoice_dict_out() + self.assertIn("RegistroAlta", result) + alta = result["RegistroAlta"] + + self.assertEqual( + alta["IDFactura"]["IDEmisorFactura"], + self.company.partner_id._parse_aeat_vat_info()[2], + ) + self.assertEqual(alta["IDFactura"]["NumSerieFactura"], "SIM/0001") + self.assertEqual(alta["TipoFactura"], "F2") + self.assertEqual(float(alta["CuotaTotal"]), 21.0) + self.assertEqual(float(alta["ImporteTotal"]), 121.0) + + def test_verifactu_chaining_first_order(self): + """Test chaining when there's no previous POS order""" + self.pos_config.verifactu_last_invoice_id = False + orders_data = [self._create_ui_order_data()] + order_ids = self.env["pos.order"].create_from_ui(orders_data) + order = self.env["pos.order"].browse(order_ids[0]["id"]) + + result = order._get_chaining_invoice_dict() + self.assertEqual( + result, + {"PrimerRegistro": "S"}, + "Should indicate first record when no previous order exists", + ) + self.assertEqual( + self.pos_config.verifactu_last_invoice_id.id, + order.id, + "Config's last order should be updated for first record", + ) + + def test_verifactu_chaining_with_previous(self): + """Test chaining when there's a previous POS order""" + orders_data = [self._create_ui_order_data()] + first_order_ids = self.env["pos.order"].create_from_ui(orders_data) + first_order = self.env["pos.order"].browse(first_order_ids[0]["id"]) + first_order.verifactu_hash = "TEST_HASH" + self.pos_config.verifactu_last_invoice_id = first_order.id + + second_order_data = self._create_ui_order_data(amount=200) + second_order_data["data"]["name"] = "Order 0002" + second_order_data["data"]["uid"] = str(uuid.uuid4()) # New unique ID + second_order_data["id"] = second_order_data["data"]["uid"] + second_order_ids = self.env["pos.order"].create_from_ui([second_order_data]) + second_order = self.env["pos.order"].browse(second_order_ids[0]["id"]) + + result = second_order._get_chaining_invoice_dict() + self.assertIn("RegistroAnterior", result) + self.assertEqual( + result["RegistroAnterior"]["NumSerieFactura"], + "SIM/0001", + "Should contain previous order reference", + ) + self.assertEqual( + result["RegistroAnterior"]["Huella"], + "TEST_HASH", + "Should contain previous order hash", + ) + self.assertEqual( + self.pos_config.verifactu_last_invoice_id.id, + second_order.id, + "Should update config's last order reference", + ) + + def test_verifactu_chaining_operational_error(self): + """Test handling of OperationalError during chaining""" + + def mock_execute(*args, **kwargs): + raise OperationalError("Test lock error") + + orders_data = [self._create_ui_order_data()] + first_order_ids = self.env["pos.order"].create_from_ui(orders_data) + first_order = self.env["pos.order"].browse(first_order_ids[0]["id"]) + self.pos_config.verifactu_last_invoice_id = first_order.id + + second_order_data = self._create_ui_order_data(amount=200) + second_order_data["data"]["name"] = "Order 0002" + second_order_data["data"]["uid"] = str(uuid.uuid4()) # New unique ID + second_order_data["id"] = second_order_data["data"]["uid"] + + old_execute = self.cr.execute + with self.assertRaises(OperationalError): + with self.cr.savepoint(): + self.cr.execute = mock_execute + second_order_ids = self.env["pos.order"].create_from_ui( + [second_order_data] + ) + self.cr.execute = old_execute + + self.assertEqual( + self.pos_config.verifactu_last_invoice_id.id, + first_order.id, + "Should not update config's last order reference on error", + ) + + second_order_ids = self.env["pos.order"].create_from_ui([second_order_data]) + second_order = self.env["pos.order"].browse(second_order_ids[0]["id"]) + self.assertEqual( + self.pos_config.verifactu_last_invoice_id.id, + second_order.id, + "Should have updates config's last order to second_order", + ) + + def test_verifactu_chaining_invoiced_pos_order(self): + """Test that invoiced POS orders are added to POS config chain""" + orders_data = [self._create_ui_order_data()] + first_order_ids = self.env["pos.order"].create_from_ui(orders_data) + first_order = self.env["pos.order"].browse(first_order_ids[0]["id"]) + first_order.verifactu_hash = "FIRST_HASH" + self.pos_config.verifactu_last_invoice_id = first_order.id + + second_order_data = self._create_ui_order_data(amount=200, simplified=False) + second_order_data["data"]["name"] = "Order 0002" + second_order_data["data"]["uid"] = str(uuid.uuid4()) + second_order_data["id"] = second_order_data["data"]["uid"] + second_order_ids = self.env["pos.order"].create_from_ui([second_order_data]) + second_order = self.env["pos.order"].browse(second_order_ids[0]["id"]) + + second_order.company_id.verifactu_last_invoice_id = False + + second_order.action_pos_order_invoice() + invoice = second_order.account_move + + result = invoice._get_chaining_invoice_dict() + self.assertIn( + "RegistroAnterior", result, "Invoice should have previous record info" + ) + self.assertEqual( + result["RegistroAnterior"]["Huella"], + "FIRST_HASH", + "Invoice should link to previous POS order hash", + ) + self.assertEqual( + self.pos_config.verifactu_last_invoice_id.id, + second_order.id, + "Config's last order should be the POS order, not the invoice", + ) + self.assertFalse(invoice.company_id.verifactu_last_invoice_id.exists()) diff --git a/l10n_es_aeat_verifactu_pos/views/pos_order_view.xml b/l10n_es_aeat_verifactu_pos/views/pos_order_view.xml new file mode 100644 index 00000000000..1a0c4d16aff --- /dev/null +++ b/l10n_es_aeat_verifactu_pos/views/pos_order_view.xml @@ -0,0 +1,89 @@ + + + + pos.order.verifactu.form + pos.order + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/requirements.txt b/requirements.txt index 9645c70aafe..db48ef0a844 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,8 +4,10 @@ deepdiff<8 pycountry pycryptodome qrcode +requests requests_pkcs12==1.22 suds-py3 unidecode xmlsig xmltodict +zeep diff --git a/setup/l10n_es_aeat_verifactu/odoo/addons/l10n_es_aeat_verifactu b/setup/l10n_es_aeat_verifactu/odoo/addons/l10n_es_aeat_verifactu new file mode 120000 index 00000000000..fdc0cc9a69d --- /dev/null +++ b/setup/l10n_es_aeat_verifactu/odoo/addons/l10n_es_aeat_verifactu @@ -0,0 +1 @@ +../../../../l10n_es_aeat_verifactu \ No newline at end of file diff --git a/setup/l10n_es_aeat_verifactu/setup.py b/setup/l10n_es_aeat_verifactu/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/l10n_es_aeat_verifactu/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/l10n_es_aeat_verifactu_pos/odoo/addons/l10n_es_aeat_verifactu_pos b/setup/l10n_es_aeat_verifactu_pos/odoo/addons/l10n_es_aeat_verifactu_pos new file mode 120000 index 00000000000..ed677d45f65 --- /dev/null +++ b/setup/l10n_es_aeat_verifactu_pos/odoo/addons/l10n_es_aeat_verifactu_pos @@ -0,0 +1 @@ +../../../../l10n_es_aeat_verifactu_pos \ No newline at end of file diff --git a/setup/l10n_es_aeat_verifactu_pos/setup.py b/setup/l10n_es_aeat_verifactu_pos/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/l10n_es_aeat_verifactu_pos/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)