diff --git a/l10n_es_aeat_sii_oca/models/__init__.py b/l10n_es_aeat_sii_oca/models/__init__.py index 2d66c4d5fc3..3cf6d177d86 100644 --- a/l10n_es_aeat_sii_oca/models/__init__.py +++ b/l10n_es_aeat_sii_oca/models/__init__.py @@ -5,5 +5,6 @@ from . import product_product from . import queue_job from . import account_fiscal_position +from . import sii_mixin from . import account_move from . import res_partner diff --git a/l10n_es_aeat_sii_oca/models/account_move.py b/l10n_es_aeat_sii_oca/models/account_move.py index 7ffd9dfac74..a838e75b64f 100644 --- a/l10n_es_aeat_sii_oca/models/account_move.py +++ b/l10n_es_aeat_sii_oca/models/account_move.py @@ -7,26 +7,19 @@ # Copyright 2021 Tecnativa - João Marques # Copyright 2022 ForgeFlow - Lois Rilo # Copyright 2011-2023 Tecnativa - Pedro M. Baeza +# Copyright 2023 Aures Tic - Almudena de la Puente +# Copyright 2023 Aures Tic - Jose Zambudio # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import json import logging -from requests import Session - from odoo import _, api, exceptions, fields, models -from odoo.exceptions import ValidationError from odoo.modules.registry import Registry -from odoo.tools.float_utils import float_compare +SII_VALID_INVOICE_STATES = ["posted"] _logger = logging.getLogger(__name__) -try: - from zeep import Client - from zeep.plugins import HistoryPlugin - from zeep.transports import Transport -except (ImportError, IOError) as err: - _logger.debug(err) try: from odoo.addons.queue_job.job import job @@ -39,37 +32,10 @@ def empty_decorator_factory(*argv, **kwargs): job = empty_decorator_factory -SII_STATES = [ - ("not_sent", "Not sent"), - ("sent", "Sent"), - ("sent_w_errors", "Accepted with errors"), - ("sent_modified", "Registered in SII but last modifications not sent"), - ("cancelled", "Cancelled"), - ("cancelled_modified", "Cancelled in SII but last modifications not sent"), -] -SII_VERSION = "1.1" -SII_MACRODATA_LIMIT = 100000000.0 -SII_VALID_INVOICE_STATES = ["posted"] - - -def round_by_keys(elem, search_keys, prec=2): - """This uses ``round`` method directly as if has been tested that Odoo's - ``float_round`` still returns incorrect amounts for certain values. Try - 3 units x 3,77 €/unit with 10% tax and you will be hit by the error - (on regular x86 architectures).""" - if isinstance(elem, dict): - for key, value in elem.items(): - if key in search_keys: - elem[key] = round(elem[key], prec) - else: - round_by_keys(value, search_keys) - elif isinstance(elem, list): - for value in elem: - round_by_keys(value, search_keys) - class AccountMove(models.Model): - _inherit = "account.move" + _name = "account.move" + _inherit = ["account.move", "sii.mixin"] def _get_default_type(self): context = self.env.context @@ -79,50 +45,6 @@ def _default_sii_refund_type(self): inv_type = self._get_default_type() return "I" if inv_type in ["out_refund", "in_refund"] else False - sii_description = fields.Text( - string="SII computed description", - compute="_compute_sii_description", - default="/", - store=True, - readonly=False, - copy=False, - ) - sii_state = fields.Selection( - selection=SII_STATES, - string="SII send state", - default="not_sent", - readonly=True, - copy=False, - help="Indicates the state of this invoice in relation with the " - "presentation at the SII", - ) - sii_csv = fields.Char(string="SII CSV", copy=False, readonly=True) - sii_return = fields.Text(string="SII Return", copy=False, readonly=True) - sii_header_sent = fields.Text( - string="SII last header sent", - copy=False, - readonly=True, - ) - sii_content_sent = fields.Text( - string="SII last content sent", - copy=False, - readonly=True, - ) - sii_send_error = fields.Text(string="SII Send Error", readonly=True, copy=False) - sii_send_failed = fields.Boolean( - string="SII send failed", - copy=False, - help="Indicates that the last attempt to communicate this invoice to " - "the SII has failed. See SII return for details", - ) - sii_refund_type = fields.Selection( - selection=[ - # ('S', 'By substitution'), - Removed as not fully supported - ("I", "By differences"), - ], - string="SII Refund Type", - default=lambda self: self._default_sii_refund_type(), - ) sii_refund_specific_invoice_type = fields.Selection( selection=[ ("R1", "Error based on law and Art. 80 One and Two LIVA (R1)"), @@ -134,28 +56,6 @@ def _default_sii_refund_type(self): " of article 80 of LIVA for notifying to SII with the proper" " invoice type.", ) - sii_account_registration_date = fields.Date( - string="SII account registration date", - readonly=True, - copy=False, - help="Indicates the account registration date set at the SII, which " - "must be the date when the invoice is recorded in the system and " - "is independent of the date of the accounting entry of the " - "invoice", - ) - sii_registration_key_domain = fields.Char( - compute="_compute_sii_registration_key_domain", - string="SII registration key domain", - ) - sii_registration_key = fields.Many2one( - comodel_name="aeat.sii.mapping.registration.keys", - string="SII registration key", - compute="_compute_sii_registration_key", - store=True, - readonly=False, - # required=True, This is not set as required here to avoid the - # set not null constraint warning - ) sii_registration_key_additional1 = fields.Many2one( comodel_name="aeat.sii.mapping.registration.keys", string="Additional SII registration key", @@ -164,12 +64,6 @@ def _default_sii_refund_type(self): comodel_name="aeat.sii.mapping.registration.keys", string="Additional 2 SII registration key", ) - sii_registration_key_code = fields.Char( - compute="_compute_sii_registration_key_code", - readonly=True, - string="SII Code", - ) - sii_enabled = fields.Boolean(string="Enable SII", compute="_compute_sii_enabled") sii_property_location = fields.Selection( string="Real property location", copy=False, @@ -192,12 +86,6 @@ def _default_sii_refund_type(self): string="Real property cadastrial code", copy=False, ) - sii_macrodata = fields.Boolean( - string="MacroData", - help="Check to confirm that the invoice has an absolute amount " - "greater o equal to 100 000 000,00 euros.", - compute="_compute_macrodata", - ) sii_lc_operation = fields.Boolean( string="Customs - Complementary settlement", help="Check this mark if this invoice represents a complementary " @@ -214,73 +102,17 @@ def _default_sii_refund_type(self): copy=False, ) - @api.depends("sii_registration_key") - def _compute_sii_registration_key_code(self): - """ - Para evitar tiempos de instalación largos en BBDD grandes, es necesario que - sólo dependa de sii_registration_key, ya que en caso de añadirlo odoo buscará - todos los movimientos y cuando escribamos el key, aunque sea un campo no almacenado - - A partir de v16.0 este cambio ya no es necesario, ya que el sistema ya revisa que el - campo sea almacenado o que este visualizandose (en caché) - """ - for record in self: - record.sii_registration_key_code = record.sii_registration_key.code - @api.depends("move_type") def _compute_sii_registration_key_domain(self): - for record in self: - if record.move_type in {"out_invoice", "out_refund"}: - record.sii_registration_key_domain = "sale" - elif record.move_type in {"in_invoice", "in_refund"}: - record.sii_registration_key_domain = "purchase" - else: - record.sii_registration_key_domain = False + return super()._compute_sii_registration_key_domain() - @api.depends("fiscal_position_id", "move_type") + @api.depends("move_type") def _compute_sii_registration_key(self): - for invoice in self: - if invoice.fiscal_position_id: - if "out" in invoice.move_type: - key = invoice.fiscal_position_id.sii_registration_key_sale - else: - key = invoice.fiscal_position_id.sii_registration_key_purchase - # Only assign sii_registration_key if it's set in the fiscal position - if key: - invoice.sii_registration_key = key - else: - domain = [ - ("code", "=", "01"), - ("type", "=", "sale" if "out" in invoice.move_type else "purchase"), - ] - sii_key_obj = self.env["aeat.sii.mapping.registration.keys"] - invoice.sii_registration_key = sii_key_obj.search(domain, limit=1) + return super()._compute_sii_registration_key() @api.depends("amount_total") def _compute_macrodata(self): - for inv in self: - inv.sii_macrodata = ( - float_compare( - abs(inv.amount_total_signed), - SII_MACRODATA_LIMIT, - precision_digits=2, - ) - >= 0 - ) - - @api.onchange("sii_refund_type") - def onchange_sii_refund_type(self): - if ( - self.sii_enabled - and self.sii_refund_type == "S" - and not self.refund_invoice_id - ): - self.sii_refund_type = False - return { - "warning": { - "message": _("You must have at least one refunded invoice"), - } - } + return super()._compute_macrodata() def _sii_get_partner(self): return self.commercial_partner_id @@ -329,65 +161,11 @@ def write(self, vals): def unlink(self): """A registered invoice at the SII cannot be deleted""" - for invoice in self.filtered(lambda x: x.is_invoice()): - if invoice.sii_state != "not_sent": - raise exceptions.UserError( - _("You cannot delete an invoice already registered at the " "SII.") - ) - return super().unlink() - - def _get_sii_taxes_map(self, codes): - """Return the codes that correspond to that sii map line codes. - - :param self: Single invoice record. - :param codes: List of code strings to get the mapping. - :return: Recordset with the corresponding codes - """ - self.ensure_one() - map_obj = self.env["aeat.sii.map"].sudo() - sii_map = map_obj.search( - [ - "|", - ("date_from", "<=", self.date), - ("date_from", "=", False), - "|", - ("date_to", ">=", self.date), - ("date_to", "=", False), - ], - limit=1, - ) - tax_templates = sii_map.map_lines.filtered(lambda x: x.code in codes).taxes - return self.company_id.get_taxes_from_templates(tax_templates) - - def _change_date_format(self, date): - datetimeobject = fields.Date.to_date(date) - new_date = datetimeobject.strftime("%d-%m-%Y") - return new_date - - def _get_sii_header(self, tipo_comunicacion=False, cancellation=False): - """Builds SII send header - - :param tipo_comunicacion String 'A0': new reg, 'A1': modification - :param cancellation Bool True when the communitacion es for invoice - cancellation - :return Dict with header data depending on cancellation - """ - self.ensure_one() - company = self.company_id - if not company.vat: + if self.filtered(lambda x: x.is_invoice())._check_unlink_sii_sent(): raise exceptions.UserError( - _("No VAT configured for the company '{}'").format(company.name) + _("You cannot delete an invoice already registered at the SII.") ) - header = { - "IDVersionSii": SII_VERSION, - "Titular": { - "NombreRazon": self.company_id.name[0:120], - "NIF": company.partner_id._parse_aeat_vat_info()[2], - }, - } - if not cancellation: - header.update({"TipoComunicacion": tipo_comunicacion}) - return header + return super().unlink() def _get_sii_tax_req(self, tax): """Get the associated req tax for the specified tax. @@ -397,7 +175,7 @@ def _get_sii_tax_req(self, tax): :return: REQ tax (or empty recordset) linked to the provided tax. """ self.ensure_one() - taxes_req = self._get_sii_taxes_map(["RE"]) + taxes_req = self._get_sii_taxes_map(["RE"], self._get_document_fiscal_date()) re_lines = self.line_ids.filtered( lambda x: tax in x.tax_ids and x.tax_ids & taxes_req ) @@ -437,32 +215,8 @@ def _get_sii_tax_dict(self, tax_line, tax_lines): tax_dict["CuotaRecargoEquivalencia"] = tax_lines[req_tax]["amount"] return tax_dict - def _is_sii_type_breakdown_required(self, taxes_dict): - """Calculates if the block 'DesgloseTipoOperacion' is required for - the invoice communication.""" - self.ensure_one() - if "DesgloseFactura" not in taxes_dict: - return False - country_code = self._get_sii_country_code() - sii_gen_type = self._get_sii_gen_type() - if "DesgloseTipoOperacion" in taxes_dict: - # DesgloseTipoOperacion and DesgloseFactura are Exclusive - return True - elif sii_gen_type in (2, 3): - # DesgloseTipoOperacion required for Intracommunity and - # Export operations - return True - elif sii_gen_type == 1 and country_code != "ES": - # DesgloseTipoOperacion required for national operations - # with 'IDOtro' in the SII identifier block - return True - elif sii_gen_type == 1 and (self._sii_get_partner().vat or "").startswith( - "ESN" - ): - # DesgloseTipoOperacion required if customer's country is Spain and - # has a NIF which starts with 'N' - return True - return False + def _get_document_amount_total(self): + return self.amount_total_signed def _get_tax_info(self): self.ensure_one() @@ -495,17 +249,21 @@ def _get_sii_out_taxes(self): # noqa: C901 """ self.ensure_one() taxes_dict = {} - taxes_sfesb = self._get_sii_taxes_map(["SFESB"]) - taxes_sfesbe = self._get_sii_taxes_map(["SFESBE"]) - taxes_sfesisp = self._get_sii_taxes_map(["SFESISP"]) + taxes_sfesb = self._get_sii_taxes_map(["SFESB"], self.date) + taxes_sfesbe = self._get_sii_taxes_map(["SFESBE"], self.date) + taxes_sfesisp = self._get_sii_taxes_map(["SFESISP"], self.date) # taxes_sfesisps = self._get_taxes_map(['SFESISPS']) - taxes_sfens = self._get_sii_taxes_map(["SFENS"]) - taxes_sfess = self._get_sii_taxes_map(["SFESS"]) - taxes_sfesse = self._get_sii_taxes_map(["SFESSE"]) - taxes_sfesns = self._get_sii_taxes_map(["SFESNS"]) - taxes_not_in_total = self._get_sii_taxes_map(["NotIncludedInTotal"]) - taxes_not_in_total_neg = self._get_sii_taxes_map(["NotIncludedInTotalNegative"]) - base_not_in_total = self._get_sii_taxes_map(["BaseNotIncludedInTotal"]) + taxes_sfens = self._get_sii_taxes_map(["SFENS"], self.date) + taxes_sfess = self._get_sii_taxes_map(["SFESS"], self.date) + taxes_sfesse = self._get_sii_taxes_map(["SFESSE"], self.date) + taxes_sfesns = self._get_sii_taxes_map(["SFESNS"], self.date) + taxes_not_in_total = self._get_sii_taxes_map(["NotIncludedInTotal"], self.date) + taxes_not_in_total_neg = self._get_sii_taxes_map( + ["NotIncludedInTotalNegative"], self.date + ) + base_not_in_total = self._get_sii_taxes_map( + ["BaseNotIncludedInTotal"], self.date + ) not_in_amount_total = 0 exempt_cause = self._get_sii_exempt_cause(taxes_sfesbe + taxes_sfesse) tax_lines = self._get_tax_info() @@ -624,15 +382,19 @@ def _get_sii_in_taxes(self): """ self.ensure_one() taxes_dict = {} - taxes_sfrs = self._get_sii_taxes_map(["SFRS"]) - taxes_sfrsa = self._get_sii_taxes_map(["SFRSA"]) - taxes_sfrisp = self._get_sii_taxes_map(["SFRISP"]) - taxes_sfrns = self._get_sii_taxes_map(["SFRNS"]) - taxes_sfrnd = self._get_sii_taxes_map(["SFRND"]) - taxes_sfrbi = self._get_sii_taxes_map(["SFRBI"]) - taxes_not_in_total = self._get_sii_taxes_map(["NotIncludedInTotal"]) - taxes_not_in_total_neg = self._get_sii_taxes_map(["NotIncludedInTotalNegative"]) - base_not_in_total = self._get_sii_taxes_map(["BaseNotIncludedInTotal"]) + taxes_sfrs = self._get_sii_taxes_map(["SFRS"], self.date) + taxes_sfrsa = self._get_sii_taxes_map(["SFRSA"], self.date) + taxes_sfrisp = self._get_sii_taxes_map(["SFRISP"], self.date) + taxes_sfrns = self._get_sii_taxes_map(["SFRNS"], self.date) + taxes_sfrnd = self._get_sii_taxes_map(["SFRND"], self.date) + taxes_sfrbi = self._get_sii_taxes_map(["SFRBI"], self.date) + taxes_not_in_total = self._get_sii_taxes_map(["NotIncludedInTotal"], self.date) + taxes_not_in_total_neg = self._get_sii_taxes_map( + ["NotIncludedInTotalNegative"], self.date + ) + base_not_in_total = self._get_sii_taxes_map( + ["BaseNotIncludedInTotal"], self.date + ) tax_amount = 0.0 not_in_amount_total = 0.0 tax_lines = self._get_tax_info() @@ -676,157 +438,88 @@ def _get_sii_in_taxes(self): base_dict["DetalleIVA"].append(tax_dict) return taxes_dict, tax_amount, not_in_amount_total - def _is_sii_simplified_invoice(self): - """Inheritable method to allow control when an - invoice are simplified or normal""" - partner = self._sii_get_partner() - is_simplified = partner.sii_simplified_invoice - return is_simplified + def _get_mapping_key(self): + return self.move_type def _sii_check_exceptions(self): - """Inheritable method for exceptions control when sending SII invoices.""" - self.ensure_one() - gen_type = self._get_sii_gen_type() - partner = self._sii_get_partner() - country_code = self._get_sii_country_code() + res = super()._sii_check_exceptions() is_simplified_invoice = self._is_sii_simplified_invoice() - if is_simplified_invoice and self.move_type[:2] == "in": raise exceptions.UserError( _("You can't make a supplier simplified invoice.") ) - if ( - (gen_type != 3 or country_code == "ES") - and not partner.vat - and not is_simplified_invoice - ): - raise exceptions.UserError(_("The partner has not a VAT configured.")) - if not self.company_id.chart_template_id: - raise exceptions.UserError( - _("You have to select what account chart template use this" " company.") - ) - if not self.company_id.sii_enabled: - raise exceptions.UserError(_("This company doesn't have SII enabled.")) - if not self.sii_enabled: - raise exceptions.UserError(_("This invoice is not SII enabled.")) if not self.ref and self.move_type in ["in_invoice", "in_refund"]: raise exceptions.UserError(_("The supplier number invoice is required")) - - def _get_account_registration_date(self): - """Hook method to allow the setting of the account registration date - of each supplier invoice. The SII recommends to set the send date as - the default value (point 9.3 of the document - SII_Descripcion_ServicioWeb_v0.7.pdf), so by default we return - the current date or, if exists, the stored - sii_account_registration_date - :return String date in the format %Y-%m-%d""" - self.ensure_one() - return self.sii_account_registration_date or fields.Date.today() + return res def _get_sii_invoice_type(self): - tipo_factura = "" + invoice_type = "" if self.sii_lc_operation: return "LC" if self.move_type in ["in_invoice", "in_refund"]: - tipo_factura = "R4" if self.move_type == "in_refund" else "F1" + invoice_type = "R4" if self.move_type == "in_refund" else "F1" elif self.move_type in ["out_invoice", "out_refund"]: is_simplified = self._is_sii_simplified_invoice() - tipo_factura = "F2" if is_simplified else "F1" + invoice_type = "F2" if is_simplified else "F1" if self.move_type == "out_refund": if self.sii_refund_specific_invoice_type: - tipo_factura = self.sii_refund_specific_invoice_type + invoice_type = self.sii_refund_specific_invoice_type else: - tipo_factura = "R5" if is_simplified else "R1" - return tipo_factura + invoice_type = "R5" if is_simplified else "R1" + return invoice_type def _get_sii_invoice_dict_out(self, cancel=False): - """Build dict with data to send to AEAT WS for invoice types: - out_invoice and out_refund. - - :param cancel: It indicates if the dictionary is for sending a - cancellation of the invoice. - :return: invoices (dict) : Dict XML with data for this invoice. - """ - self.ensure_one() - invoice_date = self._change_date_format(self.invoice_date) - partner = self._sii_get_partner() - company = self.company_id - ejercicio = fields.Date.to_date(self.date).year - periodo = "%02d" % fields.Date.to_date(self.date).month - is_simplified_invoice = self._is_sii_simplified_invoice() - serial_number = (self.name or "")[0:60] + inv_dict = super()._get_sii_invoice_dict_out(cancel=cancel) if self.thirdparty_invoice: - serial_number = self.thirdparty_number[0:60] - inv_dict = { - "IDFactura": { - "IDEmisorFactura": { - "NIF": company.partner_id._parse_aeat_vat_info()[2] - }, - # On cancelled invoices, number is not filled - "NumSerieFacturaEmisor": serial_number, - "FechaExpedicionFacturaEmisor": invoice_date, - }, - "PeriodoLiquidacion": {"Ejercicio": ejercicio, "Periodo": periodo}, - } - if not cancel: - tipo_desglose, not_in_amount_total = self._get_sii_out_taxes() - amount_total = self.amount_total_signed - not_in_amount_total - inv_dict["FacturaExpedida"] = { - "TipoFactura": self._get_sii_invoice_type(), - "ClaveRegimenEspecialOTrascendencia": (self.sii_registration_key.code), - "DescripcionOperacion": self.sii_description, - "TipoDesglose": tipo_desglose, - "ImporteTotal": amount_total, - } - if self.thirdparty_invoice: - inv_dict["FacturaExpedida"]["EmitidaPorTercerosODestinatario"] = "S" - if self.sii_macrodata: - inv_dict["FacturaExpedida"].update(Macrodato="S") - if self.sii_registration_key_additional1: - inv_dict["FacturaExpedida"].update( - { - "ClaveRegimenEspecialOTrascendenciaAdicional1": ( - self.sii_registration_key_additional1.code - ) - } - ) - if self.sii_registration_key_additional2: - inv_dict["FacturaExpedida"].update( - { - "ClaveRegimenEspecialOTrascendenciaAdicional2": ( - self.sii_registration_key_additional2.code - ) - } - ) - if self.sii_registration_key.code in ["12", "13"]: - inv_dict["FacturaExpedida"]["DatosInmueble"] = { - "DetalleInmueble": { - "SituacionInmueble": self.sii_property_location, - "ReferenciaCatastral": ( - self.sii_property_cadastrial_code or "" - ), - } + inv_dict["FacturaExpedida"]["EmitidaPorTercerosODestinatario"] = "S" + if self.sii_registration_key_additional1: + inv_dict["FacturaExpedida"].update( + { + "ClaveRegimenEspecialOTrascendenciaAdicional1": ( + self.sii_registration_key_additional1.code + ) } - exp_dict = inv_dict["FacturaExpedida"] - if not is_simplified_invoice: - # Simplified invoices don't have counterpart - exp_dict["Contraparte"] = { - "NombreRazon": partner.name[0:120], + ) + if self.sii_registration_key_additional2: + inv_dict["FacturaExpedida"].update( + { + "ClaveRegimenEspecialOTrascendenciaAdicional2": ( + self.sii_registration_key_additional2.code + ) + } + ) + if self.sii_registration_key.code in ["12", "13"]: + inv_dict["FacturaExpedida"]["DatosInmueble"] = { + "DetalleInmueble": { + "SituacionInmueble": self.sii_property_location, + "ReferenciaCatastral": (self.sii_property_cadastrial_code or ""), + } + } + exp_dict = inv_dict["FacturaExpedida"] + if self.move_type == "out_refund": + exp_dict["TipoRectificativa"] = self.sii_refund_type + if self.sii_refund_type == "S": + origin = self.refund_invoice_id + exp_dict["ImporteRectificacion"] = { + "BaseRectificada": abs(origin.amount_untaxed_signed), + "CuotaRectificada": abs( + origin.amount_total_signed - origin.amount_untaxed_signed + ), } - # Uso condicional de IDOtro/NIF - exp_dict["Contraparte"].update(self._get_sii_identifier()) - if self.move_type == "out_refund": - exp_dict["TipoRectificativa"] = self.sii_refund_type - if self.sii_refund_type == "S": - origin = self.refund_invoice_id - exp_dict["ImporteRectificacion"] = { - "BaseRectificada": abs(origin.amount_untaxed_signed), - "CuotaRectificada": abs( - origin.amount_total_signed - origin.amount_untaxed_signed - ), - } return inv_dict + def _get_document_date(self): + return self.invoice_date + + def _get_document_fiscal_date(self): + return self.date + + def _get_document_serial_number(self): + serial_number = (self.name or "")[0:60] + if self.thirdparty_invoice: + serial_number = self.thirdparty_number[0:60] + return serial_number + def _get_sii_invoice_dict_in(self, cancel=False): """Build dict with data to send to AEAT WS for invoice types: in_invoice and in_refund. @@ -903,33 +596,6 @@ def _get_sii_invoice_dict_in(self, cancel=False): } return inv_dict - def _get_sii_invoice_dict(self): - self.ensure_one() - self._sii_check_exceptions() - inv_dict = {} - if self.move_type in ["out_invoice", "out_refund"]: - inv_dict = self._get_sii_invoice_dict_out() - elif self.move_type in ["in_invoice", "in_refund"]: - inv_dict = self._get_sii_invoice_dict_in() - round_by_keys( - inv_dict, - [ - "BaseImponible", - "CuotaRepercutida", - "CuotaSoportada", - "TipoRecargoEquivalencia", - "CuotaRecargoEquivalencia", - "ImportePorArticulos7_14_Otros", - "ImporteTAIReglasLocalizacion", - "ImporteTotal", - "BaseRectificada", - "CuotaRectificada", - "CuotaDeducible", - "ImporteCompensacionREAGYP", - ], - ) - return inv_dict - def _get_cancel_sii_invoice_dict(self): self.ensure_one() self._sii_check_exceptions() @@ -939,142 +605,6 @@ def _get_cancel_sii_invoice_dict(self): return self._get_sii_invoice_dict_in(cancel=True) return {} - def _connect_params_sii(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_sii(mapping_key, self.company_id) - - def _connect_sii(self, mapping_key): - self.ensure_one() - public_crt, private_key = self.env["l10n.es.aeat.certificate"].get_certificates( - company=self.company_id - ) - params = self._connect_params_sii(mapping_key) - session = Session() - session.cert = (public_crt, private_key) - transport = Transport(session=session) - history = HistoryPlugin() - client = Client(wsdl=params["wsdl"], transport=transport, plugins=[history]) - return self._bind_sii(client, params["port_name"], params["address"]) - - def _bind_sii(self, client, port_name, address=None): - self.ensure_one() - service = client._get_service("siiService") - port = client._get_port(service, port_name) - address = address or port.binding_options["address"] - return client.create_service(port.binding.name, address) - - def _process_invoice_for_sii_send(self): - """Process invoices for sending to the SII. Adds general checks from - configuration parameters and invoice availability for SII. If the - invoice is to be sent the decides the send method: direct send or - via connector depending on 'Use connector' configuration""" - queue_obj = self.env["queue.job"].sudo() - for invoice in self: - company = invoice.company_id - if not company.use_connector: - invoice._send_invoice_to_sii() - else: - eta = company._get_sii_eta() - new_delay = ( - invoice.sudo() - .with_context(company_id=company.id) - .with_delay(eta=eta if not invoice.sii_send_failed else False) - .confirm_one_invoice() - ) - job = queue_obj.search([("uuid", "=", new_delay.uuid)], limit=1) - invoice.sudo().invoice_jobs_ids |= job - - def _send_invoice_to_sii(self): - for invoice in self.filtered(lambda i: i.state in SII_VALID_INVOICE_STATES): - if invoice.sii_state == "not_sent": - tipo_comunicacion = "A0" - else: - tipo_comunicacion = "A1" - header = invoice._get_sii_header(tipo_comunicacion) - inv_vals = { - "sii_header_sent": json.dumps(header, indent=4), - } - # add this extra try except in case _get_sii_invoice_dict fails - # if not, get the value inv_dict for the next try and except below - try: - inv_dict = invoice._get_sii_invoice_dict() - except Exception as fault: - raise ValidationError(fault) from fault - try: - serv = invoice._connect_sii(invoice.move_type) - inv_vals["sii_content_sent"] = json.dumps(inv_dict, indent=4) - if invoice.move_type in ["out_invoice", "out_refund"]: - res = serv.SuministroLRFacturasEmitidas(header, inv_dict) - elif invoice.move_type in ["in_invoice", "in_refund"]: - res = serv.SuministroLRFacturasRecibidas(header, inv_dict) - # TODO Facturas intracomunitarias 66 RIVA - # elif invoice.fiscal_position_id.id == self.env.ref( - # 'account.fp_intra').id: - # res = serv.SuministroLRDetOperacionIntracomunitaria( - # header, invoices) - res_line = res["RespuestaLinea"][0] - if res["EstadoEnvio"] == "Correcto": - inv_vals.update( - { - "sii_state": "sent", - "sii_csv": res["CSV"], - "sii_send_failed": False, - } - ) - elif ( - res["EstadoEnvio"] == "ParcialmenteCorrecto" - and res_line["EstadoRegistro"] == "AceptadoConErrores" - ): - inv_vals.update( - { - "sii_state": "sent_w_errors", - "sii_csv": res["CSV"], - "sii_send_failed": True, - } - ) - else: - inv_vals["sii_send_failed"] = True - if ( - "sii_state" in inv_vals - and not invoice.sii_account_registration_date - and invoice.move_type[:2] == "in" - ): - inv_vals[ - "sii_account_registration_date" - ] = self._get_account_registration_date() - inv_vals["sii_return"] = res - send_error = False - if res_line["CodigoErrorRegistro"]: - send_error = "{} | {}".format( - str(res_line["CodigoErrorRegistro"]), - str(res_line["DescripcionErrorRegistro"])[:60], - ) - inv_vals["sii_send_error"] = send_error - invoice.write(inv_vals) - except Exception as fault: - new_cr = Registry(self.env.cr.dbname).cursor() - env = api.Environment(new_cr, self.env.uid, self.env.context) - invoice = env["account.move"].browse(invoice.id) - inv_vals.update( - { - "sii_send_failed": True, - "sii_send_error": repr(fault)[:60], - "sii_return": repr(fault), - "sii_content_sent": json.dumps(inv_dict, indent=4), - } - ) - invoice.write(inv_vals) - new_cr.commit() - new_cr.close() - raise ValidationError(fault) from fault - def _sii_invoice_dict_not_modified(self): self.ensure_one() to_send = self._get_sii_invoice_dict() @@ -1098,9 +628,23 @@ def _post(self, soft=True): company = invoice.company_id if company.sii_method != "auto": continue - invoice._process_invoice_for_sii_send() + invoice._process_sii_send() return res + @api.onchange("sii_refund_type") + def onchange_sii_refund_type(self): + if ( + self.sii_enabled + and self.sii_refund_type == "S" + and not self.refund_invoice_id + ): + self.sii_refund_type = False + return { + "warning": { + "message": _("You must have at least one refunded invoice"), + } + } + def process_send_sii(self): return { "name": "Confirmation message for sending invoices to the SII", @@ -1112,22 +656,11 @@ def process_send_sii(self): "context": self.env.context, } - def send_sii(self): - invoices = self.filtered( - lambda i: ( - i.sii_enabled - and i.state in SII_VALID_INVOICE_STATES - and i.sii_state not in ["sent", "cancelled"] - ) - ) - if not invoices._cancel_invoice_jobs(): - raise exceptions.UserError( - _( - "You can not communicate this invoice at this moment " - "because there is a job running!" - ) - ) - invoices._process_invoice_for_sii_send() + def _get_sii_jobs_field_name(self): + return "invoice_jobs_ids" + + def _get_valid_document_states(self): + return SII_VALID_INVOICE_STATES def _cancel_invoice_to_sii(self): for invoice in self.filtered(lambda i: i.state in ["cancel"]): @@ -1188,7 +721,7 @@ def cancel_sii(self): and i.sii_state in ["sent", "sent_w_errors", "sent_modified"] ) ) - if not invoices._cancel_invoice_jobs(): + if not invoices._cancel_sii_jobs(): raise exceptions.UserError( _( "You can not communicate the cancellation of this invoice " @@ -1211,16 +744,8 @@ def cancel_sii(self): job = queue_obj.search([("uuid", "=", new_delay.uuid)], limit=1) invoice.sudo().invoice_jobs_ids |= job - def _cancel_invoice_jobs(self): - for queue in self.sudo().mapped("invoice_jobs_ids"): - if queue.state == "started": - return False - elif queue.state in ("pending", "enqueued", "failed"): - queue.unlink() - return True - def button_cancel(self): - if not self._cancel_invoice_jobs(): + if not self._cancel_sii_jobs(): raise exceptions.UserError( _("You can not cancel this invoice because" " there is a job running!") ) @@ -1235,7 +760,7 @@ def button_cancel(self): return res def button_draft(self): - if not self._cancel_invoice_jobs(): + if not self._cancel_sii_jobs(): raise exceptions.UserError( _( "You can not set to draft this invoice because" @@ -1244,126 +769,17 @@ def button_draft(self): ) return super().button_draft() - def _get_sii_gen_type(self): - """Make a choice for general invoice type - - Returns: - int: 1 (National), 2 (Intracom), 3 (Export) - """ - self.ensure_one() - partner_ident = self.fiscal_position_id.sii_partner_identification_type - if partner_ident: - res = int(partner_ident) - elif self.fiscal_position_id.name == "Régimen Intracomunitario": - res = 2 - elif self.fiscal_position_id.name == "Régimen Extracomunitario": - res = 3 - else: - res = 1 - return res - - def _get_sii_identifier(self): - """Get the SII structure for a partner identifier depending on the - conditions of the invoice. - """ - self.ensure_one() - gen_type = self._get_sii_gen_type() - ( - country_code, - identifier_type, - identifier, - ) = self._sii_get_partner()._parse_aeat_vat_info() - # Limpiar alfanum - if identifier: - identifier = "".join(e for e in identifier if e.isalnum()).upper() - else: - identifier = "NO_DISPONIBLE" - identifier_type = "06" - if gen_type == 1: - if "1117" in (self.sii_send_error or ""): - return { - "IDOtro": { - "CodigoPais": country_code, - "IDType": "07", - "ID": identifier, - } - } - else: - if identifier_type == "": - return {"NIF": identifier} - return { - "IDOtro": { - "CodigoPais": country_code, - "IDType": identifier_type, - "ID": country_code + identifier - if self.commercial_partner_id._map_aeat_country_code( - country_code - ) - in self.commercial_partner_id._get_aeat_europe_codes() - else identifier, - }, - } - elif gen_type == 2: - return {"IDOtro": {"IDType": "02", "ID": country_code + identifier}} - elif gen_type == 3 and identifier_type: - # Si usamos identificador tipo 02 en exportaciones, el envío falla con: - # {'CodigoErrorRegistro': 1104, - # 'DescripcionErrorRegistro': 'Valor del campo ID incorrecto'} - if identifier_type == "02": - identifier_type = "06" - return { - "IDOtro": { - "CodigoPais": country_code, - "IDType": identifier_type, - "ID": identifier, - }, - } - elif gen_type == 3: - return {"NIF": identifier} - - def _get_sii_exempt_cause(self, applied_taxes): - """Código de la causa de exención según 3.6 y 3.7 de la FAQ del SII. - - :param applied_taxes: Taxes that are exempt for filtering the lines. - """ - self.ensure_one() - gen_type = self._get_sii_gen_type() - if gen_type == 2: - return "E5" - else: - exempt_cause = False - product_exempt_causes = ( - self.mapped("invoice_line_ids") - .filtered( - lambda x: ( - any(tax in x.tax_ids for tax in applied_taxes) - and x.product_id.sii_exempt_cause - and x.product_id.sii_exempt_cause != "none" - ) + def _get_document_product_exempt(self, applied_taxes): + return set( + self.mapped("invoice_line_ids") + .filtered( + lambda x: ( + any(tax in x.tax_ids for tax in applied_taxes) + and x.product_id.sii_exempt_cause + and x.product_id.sii_exempt_cause != "none" ) - .mapped("product_id.sii_exempt_cause") ) - product_exempt_causes = set(product_exempt_causes) - if len(product_exempt_causes) > 1: - raise exceptions.UserError( - _("Currently there's no support for multiple exempt " "causes.") - ) - if product_exempt_causes: - exempt_cause = product_exempt_causes.pop() - elif ( - self.fiscal_position_id.sii_exempt_cause - and self.fiscal_position_id.sii_exempt_cause != "none" - ): - exempt_cause = self.fiscal_position_id.sii_exempt_cause - if gen_type == 3 and exempt_cause not in ["E2", "E3"]: - exempt_cause = "E2" - return exempt_cause - - def _get_no_taxable_cause(self): - self.ensure_one() - return ( - self.fiscal_position_id.sii_no_taxable_cause - or "ImporteTAIReglasLocalizacion" + .mapped("product_id.sii_exempt_cause") ) def is_sii_invoice(self): @@ -1375,10 +791,6 @@ def is_sii_invoice(self): """ self.ensure_one() - def _get_sii_country_code(self): - self.ensure_one() - return self._sii_get_partner()._parse_aeat_vat_info()[0] - @api.depends( "invoice_line_ids", "invoice_line_ids.name", @@ -1451,8 +863,5 @@ def _reverse_moves(self, default_values_list=None, cancel=False): ) return res - def confirm_one_invoice(self): - self.sudo()._send_invoice_to_sii() - def cancel_one_invoice(self): self.sudo()._cancel_invoice_to_sii() diff --git a/l10n_es_aeat_sii_oca/models/sii_mixin.py b/l10n_es_aeat_sii_oca/models/sii_mixin.py new file mode 100644 index 00000000000..fd094dc7972 --- /dev/null +++ b/l10n_es_aeat_sii_oca/models/sii_mixin.py @@ -0,0 +1,903 @@ +# Copyright 2021 Tecnativa - João Marques +# Copyright 2022 ForgeFlow - Lois Rilo +# Copyright 2011-2023 Tecnativa - Pedro M. Baeza +# Copyright 2023 Aures Tic - Almudena de la Puente +# Copyright 2023 Aures Tic - Jose Zambudio +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import json +import logging + +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 + +_logger = logging.getLogger(__name__) + +try: + from zeep import Client + from zeep.plugins import HistoryPlugin + from zeep.transports import Transport +except (ImportError, IOError) as err: + _logger.debug(err) + +SII_STATES = [ + ("not_sent", "Not sent"), + ("sent", "Sent"), + ("sent_w_errors", "Accepted with errors"), + ("sent_modified", "Registered in SII but last modifications not sent"), + ("cancelled", "Cancelled"), + ("cancelled_modified", "Cancelled in SII but last modifications not sent"), +] +SII_VERSION = "1.1" +SII_MACRODATA_LIMIT = 100000000.0 +SII_DATE_FORMAT = "%d-%m-%Y" + + +def round_by_keys(elem, search_keys, prec=2): + """This uses ``round`` method directly as if has been tested that Odoo's + ``float_round`` still returns incorrect amounts for certain values. Try + 3 units x 3,77 €/unit with 10% tax and you will be hit by the error + (on regular x86 architectures).""" + if isinstance(elem, dict): + for key, value in elem.items(): + if key in search_keys: + elem[key] = round(elem[key], prec) + else: + round_by_keys(value, search_keys) + elif isinstance(elem, list): + for value in elem: + round_by_keys(value, search_keys) + + +class SiiMixin(models.AbstractModel): + _name = "sii.mixin" + _description = "SII Mixin" + + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + ) + sii_description = fields.Text( + string="SII computed description", + compute="_compute_sii_description", + default="/", + store=True, + readonly=False, + copy=False, + ) + sii_state = fields.Selection( + selection=SII_STATES, + string="SII send state", + default="not_sent", + readonly=True, + copy=False, + help="Indicates the state of this document in relation with the " + "presentation at the SII", + ) + sii_csv = fields.Char(string="SII CSV", copy=False, readonly=True) + sii_return = fields.Text(string="SII Return", copy=False, readonly=True) + sii_header_sent = fields.Text( + string="SII last header sent", + copy=False, + readonly=True, + ) + sii_content_sent = fields.Text( + string="SII last content sent", + copy=False, + readonly=True, + ) + sii_send_error = fields.Text( + string="SII Send Error", + readonly=True, + copy=False, + ) + sii_send_failed = fields.Boolean( + string="SII send failed", + copy=False, + help="Indicates that the last attempt to communicate this document to " + "the SII has failed. See SII return for details", + ) + sii_refund_type = fields.Selection( + selection=[ + # ('S', 'By substitution'), - Removed as not fully supported + ("I", "By differences"), + ], + string="SII Refund Type", + default=lambda self: self._default_sii_refund_type(), + ) + sii_account_registration_date = fields.Date( + string="SII account registration date", + readonly=True, + copy=False, + help="Indicates the account registration date set at the SII, which " + "must be the date when the document is recorded in the system and " + "is independent of the date of the accounting entry of the " + "document", + ) + sii_registration_key_domain = fields.Char( + compute="_compute_sii_registration_key_domain", + string="SII registration key domain", + ) + sii_registration_key = fields.Many2one( + comodel_name="aeat.sii.mapping.registration.keys", + string="SII registration key", + compute="_compute_sii_registration_key", + store=True, + readonly=False, + # required=True, This is not set as required here to avoid the + # set not null constraint warning + ) + sii_registration_key_code = fields.Char( + compute="_compute_sii_registration_key_code", + readonly=True, + string="SII Code", + ) + sii_enabled = fields.Boolean( + string="Enable SII", + compute="_compute_sii_enabled", + ) + sii_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_macrodata", + ) + + def _default_sii_refund_type(self): + return False + + def _compute_sii_description(self): + self.sii_description = "/" + + def _compute_sii_registration_key_domain(self): + for document in self: + mapping_key = document._get_mapping_key() + if mapping_key in {"out_invoice", "out_refund"}: + document.sii_registration_key_domain = "sale" + elif mapping_key in {"in_invoice", "in_refund"}: + document.sii_registration_key_domain = "purchase" + else: + document.sii_registration_key_domain = False + + @api.depends("fiscal_position_id") + def _compute_sii_registration_key(self): + for document in self: + mapping_key = document._get_mapping_key() + if document.fiscal_position_id: + if "out" in mapping_key: + key = document.fiscal_position_id.sii_registration_key_sale + else: + key = document.fiscal_position_id.sii_registration_key_purchase + # Only assign sii_registration_key if it's set in the fiscal position + if key: + document.sii_registration_key = key + else: + domain = [ + ("code", "=", "01"), + ( + "type", + "=", + "sale" if mapping_key.startswith("out_") else "purchase", + ), + ] + sii_key_obj = self.env["aeat.sii.mapping.registration.keys"] + document.sii_registration_key = sii_key_obj.search(domain, limit=1) + + @api.depends("sii_registration_key") + def _compute_sii_registration_key_code(self): + """ + Para evitar tiempos de instalación largos en BBDD grandes, es necesario que + sólo dependa de sii_registration_key, ya que en caso de añadirlo odoo buscará + todos los movimientos y cuando escribamos el key, aunque sea un campo no almacenado + A partir de v16.0 este cambio ya no es necesario, ya que el sistema ya revisa que el + campo sea almacenado o que este visualizandose (en caché) + """ + for record in self: + record.sii_registration_key_code = record.sii_registration_key.code + + def _compute_sii_enabled(self): + raise NotImplementedError + + def _compute_macrodata(self): + for document in self: + document.sii_macrodata = ( + float_compare( + abs(document._get_document_amount_total()), + SII_MACRODATA_LIMIT, + precision_digits=2, + ) + >= 0 + ) + + def _sii_get_partner(self): + raise NotImplementedError + + def _get_sii_country_code(self): + self.ensure_one() + return self._sii_get_partner()._parse_aeat_vat_info()[0] + + def _check_unlink_sii_sent(self): + return self and not self.filtered(lambda rec: rec.sii_state != "not_sent") + + @api.model + def _get_sii_taxes_map(self, codes, date): + """Return the codes that correspond to that sii 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.sii.map"].sudo() + sii_map = map_obj.search( + [ + "|", + ("date_from", "<=", date), + ("date_from", "=", False), + "|", + ("date_to", ">=", date), + ("date_to", "=", False), + ], + limit=1, + ) + tax_templates = sii_map.map_lines.filtered(lambda x: x.code in codes).taxes + return self.company_id.get_taxes_from_templates(tax_templates) + + def _change_date_format(self, date): + datetimeobject = fields.Date.to_date(date) + new_date = datetimeobject.strftime(SII_DATE_FORMAT) + return new_date + + def _get_sii_header(self, tipo_comunicacion=False, cancellation=False): + """Builds SII 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 = { + "IDVersionSii": SII_VERSION, + "Titular": { + "NombreRazon": self.company_id.name[0:120], + "NIF": self.company_id.partner_id._parse_aeat_vat_info()[2], + }, + } + if not cancellation: + header.update({"TipoComunicacion": tipo_comunicacion}) + return header + + def _get_sii_jobs_field_name(self): + raise NotImplementedError() + + def _cancel_sii_jobs(self): + for queue in self.sudo().mapped(self._get_sii_jobs_field_name()): + if queue.state == "started": + return False + elif queue.state in ("pending", "enqueued", "failed"): + queue.unlink() + return True + + def _get_valid_document_states(self): + raise NotImplementedError() + + def send_sii(self): + documents = self.filtered( + lambda document: ( + document.sii_enabled + and document.state in self._get_valid_document_states() + and document.sii_state not in ["sent", "cancelled"] + ) + ) + if not documents._cancel_sii_jobs(): + raise UserError( + _( + "You can not communicate this document at this moment " + "because there is a job running!" + ) + ) + documents._process_sii_send() + + def _process_sii_send(self): + """Process document sending to the SII. Adds general checks from + configuration parameters and document availability for SII. If the + document is to be sent the decides the send method: direct send or + via connector depending on 'Use connector' configuration""" + queue_obj = self.env["queue.job"].sudo() + for record in self: + company = record.company_id + if not company.use_connector: + record.confirm_one_document() + else: + eta = company._get_sii_eta() + new_delay = ( + record.sudo() + .with_context(company_id=company.id) + .with_delay(eta=eta if not record.sii_send_failed else False) + .confirm_one_document() + ) + job = queue_obj.search([("uuid", "=", new_delay.uuid)], limit=1) + setattr(record.sudo(), self._get_sii_jobs_field_name(), [(4, job.id)]) + + def _bind_sii(self, client, port_name, address=None): + self.ensure_one() + service = client._get_service("siiService") + port = client._get_port(service, port_name) + address = address or port.binding_options["address"] + return client.create_service(port.binding.name, address) + + def _connect_params_sii(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_sii(mapping_key, self.company_id) + + def _connect_sii(self, mapping_key): + self.ensure_one() + public_crt, private_key = self.env["l10n.es.aeat.certificate"].get_certificates( + company=self.company_id + ) + params = self._connect_params_sii(mapping_key) + session = Session() + session.cert = (public_crt, private_key) + transport = Transport(session=session) + history = HistoryPlugin() + client = Client(wsdl=params["wsdl"], transport=transport, plugins=[history]) + return self._bind_sii(client, params["port_name"], params["address"]) + + def _get_sii_gen_type(self): + """Make a choice for general invoice type + + Returns: + int: 1 (National), 2 (Intracom), 3 (Export) + """ + self.ensure_one() + partner_ident = self.fiscal_position_id.sii_partner_identification_type + if partner_ident: + res = int(partner_ident) + elif self.fiscal_position_id.name == "Régimen Intracomunitario": + res = 2 + elif self.fiscal_position_id.name == "Régimen Extracomunitario": + res = 3 + else: + res = 1 + return res + + def _is_sii_simplified_invoice(self): + """Inheritable method to allow control when an + invoice are simplified or normal""" + partner = self._sii_get_partner() + return partner.sii_simplified_invoice + + def _sii_check_exceptions(self): + """Inheritable method for exceptions control when sending SII invoices.""" + self.ensure_one() + gen_type = self._get_sii_gen_type() + partner = self._sii_get_partner() + country_code = self._get_sii_country_code() + is_simplified_invoice = self._is_sii_simplified_invoice() + if ( + (gen_type != 3 or country_code == "ES") + and not partner.vat + and not is_simplified_invoice + ): + raise UserError(_("The partner has not a VAT configured.")) + if not self.company_id.chart_template_id: + raise UserError( + _("You have to select what account chart template use this" " company.") + ) + if not self.company_id.sii_enabled: + raise UserError(_("This company doesn't have SII enabled.")) + if not self.sii_enabled: + raise UserError(_("This invoice is not SII enabled.")) + + def _get_mapping_key(self): + raise NotImplementedError() + + def _get_document_date(self): + raise NotImplementedError() + + def _get_document_fiscal_date(self): + raise NotImplementedError() + + def _get_document_fiscal_year(self): + return fields.Date.to_date(self._get_document_fiscal_date()).year + + def _get_document_period(self): + return "%02d" % fields.Date.to_date(self._get_document_fiscal_date()).month + + def _get_document_serial_number(self): + raise NotImplementedError() + + def _get_document_product_exempt(self, applied_taxes): + raise NotImplementedError() + + def _get_sii_exempt_cause(self, applied_taxes): + """Código de la causa de exención según 3.6 y 3.7 de la FAQ del SII. + + :param applied_taxes: Taxes that are exempt for filtering the lines. + """ + self.ensure_one() + gen_type = self._get_sii_gen_type() + if gen_type == 2: + return "E5" + else: + exempt_cause = False + product_exempt_causes = self._get_document_product_exempt(applied_taxes) + if len(product_exempt_causes) > 1: + raise UserError( + _("Currently there's no support for multiple exempt causes.") + ) + if product_exempt_causes: + exempt_cause = product_exempt_causes.pop() + elif ( + self.fiscal_position_id.sii_exempt_cause + and self.fiscal_position_id.sii_exempt_cause != "none" + ): + exempt_cause = self.fiscal_position_id.sii_exempt_cause + if gen_type == 3 and exempt_cause not in ["E2", "E3"]: + exempt_cause = "E2" + return exempt_cause + + def _get_tax_info(self): + raise NotImplementedError() + + def _get_sii_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. + """ + raise NotImplementedError() + + @api.model + def _get_sii_tax_dict(self, tax_line, tax_lines): + """Get the SII 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 SII 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), "BaseImponible": tax_base_amount} + if self._get_mapping_key() in ["out_invoice", "out_refund"]: + key = "CuotaRepercutida" + else: + key = "CuotaSoportada" + tax_dict[key] = tax_line["amount"] + # Recargo de equivalencia + req_tax = self._get_sii_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_no_taxable_cause(self): + self.ensure_one() + return ( + self.fiscal_position_id.sii_no_taxable_cause + or "ImporteTAIReglasLocalizacion" + ) + + def _is_sii_type_breakdown_required(self, taxes_dict): + """Calculates if the block 'DesgloseTipoOperacion' is required for + the invoice communication.""" + self.ensure_one() + if "DesgloseFactura" not in taxes_dict: + return False + country_code = self._get_sii_country_code() + sii_gen_type = self._get_sii_gen_type() + if "DesgloseTipoOperacion" in taxes_dict: + # DesgloseTipoOperacion and DesgloseFactura are Exclusive + return True + elif sii_gen_type in (2, 3): + # DesgloseTipoOperacion required for Intracommunity and + # Export operations + return True + elif sii_gen_type == 1 and country_code != "ES": + # DesgloseTipoOperacion required for national operations + # with 'IDOtro' in the SII identifier block + return True + elif sii_gen_type == 1 and (self._sii_get_partner().vat or "").startswith( + "ESN" + ): + # DesgloseTipoOperacion required if customer's country is Spain and + # has a NIF which starts with 'N' + return True + return False + + def _get_sii_out_taxes(self): # noqa: C901 + """Get the taxes for sales documents. + + :param self: Single document record. + """ + self.ensure_one() + taxes_dict = {} + date = self._get_document_fiscal_date() + taxes_sfesb = self._get_sii_taxes_map(["SFESB"], date) + taxes_sfesbe = self._get_sii_taxes_map(["SFESBE"], date) + taxes_sfesisp = self._get_sii_taxes_map(["SFESISP"], date) + # taxes_sfesisps = self._get_taxes_map(['SFESISPS']) + taxes_sfens = self._get_sii_taxes_map(["SFENS"], date) + taxes_sfess = self._get_sii_taxes_map(["SFESS"], date) + taxes_sfesse = self._get_sii_taxes_map(["SFESSE"], date) + taxes_sfesns = self._get_sii_taxes_map(["SFESNS"], date) + taxes_not_in_total = self._get_sii_taxes_map(["NotIncludedInTotal"], date) + taxes_not_in_total_neg = self._get_sii_taxes_map( + ["NotIncludedInTotalNegative"], date + ) + base_not_in_total = self._get_sii_taxes_map(["BaseNotIncludedInTotal"], date) + not_in_amount_total = 0 + exempt_cause = self._get_sii_exempt_cause(taxes_sfesbe + taxes_sfesse) + tax_lines = self._get_tax_info() + for tax_line in tax_lines.values(): + tax = tax_line["tax"] + breakdown_taxes = taxes_sfesb + taxes_sfesisp + taxes_sfens + taxes_sfesbe + if tax in taxes_not_in_total: + not_in_amount_total += tax_line["amount"] + elif tax in taxes_not_in_total_neg: + not_in_amount_total -= tax_line["amount"] + elif tax in base_not_in_total: + not_in_amount_total += tax_line["base"] + if tax in breakdown_taxes: + tax_breakdown = taxes_dict.setdefault("DesgloseFactura", {}) + if tax in (taxes_sfesb + taxes_sfesbe + taxes_sfesisp): + sub_dict = tax_breakdown.setdefault("Sujeta", {}) + # TODO l10n_es no tiene impuesto exento de bienes + # corrientes nacionales + if tax in taxes_sfesbe: + exempt_dict = sub_dict.setdefault( + "Exenta", + {"DetalleExenta": [{"BaseImponible": 0}]}, + ) + det_dict = exempt_dict["DetalleExenta"][0] + if exempt_cause: + det_dict["CausaExencion"] = exempt_cause + det_dict["BaseImponible"] += tax_line["base"] + else: + sub_dict.setdefault( + "NoExenta", + { + "TipoNoExenta": ("S2" if tax in taxes_sfesisp else "S1"), + "DesgloseIVA": {"DetalleIVA": []}, + }, + ) + not_ex_type = sub_dict["NoExenta"]["TipoNoExenta"] + if tax in taxes_sfesisp: + is_s3 = not_ex_type == "S1" + else: + is_s3 = not_ex_type == "S2" + if is_s3: + sub_dict["NoExenta"]["TipoNoExenta"] = "S3" + sub_dict["NoExenta"]["DesgloseIVA"]["DetalleIVA"].append( + self._get_sii_tax_dict(tax_line, tax_lines), + ) + # No sujetas + if tax in taxes_sfens: + # ImporteTAIReglasLocalizacion or ImportePorArticulos7_14_Otros + default_no_taxable_cause = self._get_no_taxable_cause() + nsub_dict = tax_breakdown.setdefault( + "NoSujeta", + {default_no_taxable_cause: 0}, + ) + nsub_dict[default_no_taxable_cause] += tax_line["base"] + if tax in (taxes_sfess + taxes_sfesse + taxes_sfesns): + type_breakdown = taxes_dict.setdefault( + "DesgloseTipoOperacion", + {"PrestacionServicios": {}}, + ) + if tax in (taxes_sfesse + taxes_sfess): + type_breakdown["PrestacionServicios"].setdefault("Sujeta", {}) + service_dict = type_breakdown["PrestacionServicios"] + if tax in taxes_sfesse: + exempt_dict = service_dict["Sujeta"].setdefault( + "Exenta", + {"DetalleExenta": [{"BaseImponible": 0}]}, + ) + det_dict = exempt_dict["DetalleExenta"][0] + if exempt_cause: + det_dict["CausaExencion"] = exempt_cause + det_dict["BaseImponible"] += tax_line["base"] + if tax in taxes_sfess: + # TODO l10n_es_ no tiene impuesto ISP de servicios + # if tax in taxes_sfesisps: + # TipoNoExenta = 'S2' + # else: + service_dict["Sujeta"].setdefault( + "NoExenta", + {"TipoNoExenta": "S1", "DesgloseIVA": {"DetalleIVA": []}}, + ) + sub = type_breakdown["PrestacionServicios"]["Sujeta"]["NoExenta"][ + "DesgloseIVA" + ]["DetalleIVA"] + sub.append(self._get_sii_tax_dict(tax_line, tax_lines)) + if tax in taxes_sfesns: + nsub_dict = service_dict.setdefault( + "NoSujeta", + {"ImporteTAIReglasLocalizacion": 0}, + ) + nsub_dict["ImporteTAIReglasLocalizacion"] += tax_line["base"] + # Ajustes finales breakdown + # - DesgloseFactura y DesgloseTipoOperacion son excluyentes + # - Ciertos condicionantes obligan DesgloseTipoOperacion + if self._is_sii_type_breakdown_required(taxes_dict): + taxes_dict.setdefault("DesgloseTipoOperacion", {}) + taxes_dict["DesgloseTipoOperacion"]["Entrega"] = taxes_dict[ + "DesgloseFactura" + ] + del taxes_dict["DesgloseFactura"] + return taxes_dict, not_in_amount_total + + def _get_document_amount_total(self): + raise NotImplementedError() + + def _get_sii_invoice_type(self): + raise NotImplementedError() + + def _get_sii_identifier(self): + """Get the SII structure for a partner identifier depending on the + conditions of the invoice. + """ + self.ensure_one() + gen_type = self._get_sii_gen_type() + ( + country_code, + identifier_type, + identifier, + ) = self._sii_get_partner()._parse_aeat_vat_info() + # Limpiar alfanum + if identifier: + identifier = "".join(e for e in identifier if e.isalnum()).upper() + else: + identifier = "NO_DISPONIBLE" + identifier_type = "06" + if gen_type == 1: + if "1117" in (self.sii_send_error or ""): + return { + "IDOtro": { + "CodigoPais": country_code, + "IDType": "07", + "ID": identifier, + } + } + else: + if identifier_type == "": + return {"NIF": identifier} + return { + "IDOtro": { + "CodigoPais": country_code, + "IDType": identifier_type, + "ID": country_code + identifier + if self._sii_get_partner()._map_aeat_country_code(country_code) + in self._sii_get_partner()._get_aeat_europe_codes() + else identifier, + }, + } + elif gen_type == 2: + return {"IDOtro": {"IDType": "02", "ID": country_code + identifier}} + elif gen_type == 3 and identifier_type: + # Si usamos identificador tipo 02 en exportaciones, el envío falla con: + # {'CodigoErrorRegistro': 1104, + # 'DescripcionErrorRegistro': 'Valor del campo ID incorrecto'} + if identifier_type == "02": + identifier_type = "06" + return { + "IDOtro": { + "CodigoPais": country_code, + "IDType": identifier_type, + "ID": identifier, + }, + } + elif gen_type == 3: + return {"NIF": identifier} + + def _get_sii_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()) + partner = self._sii_get_partner() + company = self.company_id + fiscal_year = self._get_document_fiscal_year() + period = self._get_document_period() + is_simplified_invoice = self._is_sii_simplified_invoice() + serial_number = self._get_document_serial_number() + inv_dict = { + "IDFactura": { + "IDEmisorFactura": { + "NIF": company.partner_id._parse_aeat_vat_info()[2] + }, + # On cancelled invoices, number is not filled + "NumSerieFacturaEmisor": serial_number, + "FechaExpedicionFacturaEmisor": document_date, + }, + "PeriodoLiquidacion": { + "Ejercicio": fiscal_year, + "Periodo": period, + }, + } + if not cancel: + tipo_desglose, not_in_amount_total = self._get_sii_out_taxes() + amount_total = self._get_document_amount_total() - not_in_amount_total + inv_dict["FacturaExpedida"] = { + "TipoFactura": self._get_sii_invoice_type(), + "ClaveRegimenEspecialOTrascendencia": (self.sii_registration_key.code), + "DescripcionOperacion": self.sii_description, + "TipoDesglose": tipo_desglose, + "ImporteTotal": amount_total, + } + if self.sii_macrodata: + inv_dict["FacturaExpedida"].update(Macrodato="S") + exp_dict = inv_dict["FacturaExpedida"] + if not is_simplified_invoice: + # Simplified invoices don't have counterpart + exp_dict["Contraparte"] = { + "NombreRazon": partner.name[0:120], + } + # Uso condicional de IDOtro/NIF + exp_dict["Contraparte"].update(self._get_sii_identifier()) + return inv_dict + + def _get_sii_invoice_dict_in(self, cancel=False): + """Build dict with data to send to AEAT WS for invoice types: + in_invoice and in_refund. + + :param cancel: It indicates if the dictionary if for sending a + cancellation of the invoice. + :return: invoices (dict) : Dict XML with data for this invoice. + """ + raise NotImplementedError() + + def _get_sii_invoice_dict(self): + self.ensure_one() + self._sii_check_exceptions() + inv_dict = {} + mapping_key = self._get_mapping_key() + if mapping_key in ["out_invoice", "out_refund"]: + inv_dict = self._get_sii_invoice_dict_out() + elif mapping_key in ["in_invoice", "in_refund"]: + inv_dict = self._get_sii_invoice_dict_in() + round_by_keys( + inv_dict, + [ + "BaseImponible", + "CuotaRepercutida", + "CuotaSoportada", + "TipoRecargoEquivalencia", + "CuotaRecargoEquivalencia", + "ImportePorArticulos7_14_Otros", + "ImporteTAIReglasLocalizacion", + "ImporteTotal", + "BaseRectificada", + "CuotaRectificada", + "CuotaDeducible", + "ImporteCompensacionREAGYP", + ], + ) + return inv_dict + + def _get_account_registration_date(self): + """Hook method to allow the setting of the account registration date + of each supplier invoice. The SII recommends to set the send date as + the default value (point 9.3 of the document + SII_Descripcion_ServicioWeb_v0.7.pdf), so by default we return + the current date or, if exists, the stored + sii_account_registration_date + :return String date in the format %Y-%m-%d""" + self.ensure_one() + return self.sii_account_registration_date or fields.Date.today() + + def _send_document_to_sii(self): + for document in self.filtered( + lambda i: i.state in self._get_valid_document_states() + ): + if document.sii_state == "not_sent": + tipo_comunicacion = "A0" + else: + tipo_comunicacion = "A1" + header = document._get_sii_header(tipo_comunicacion) + doc_vals = { + "sii_header_sent": json.dumps(header, indent=4), + } + # add this extra try except in case _get_sii_invoice_dict fails + # if not, get the value doc_dict for the next try and except below + try: + inv_dict = document._get_sii_invoice_dict() + except Exception as fault: + raise ValidationError(fault) from fault + try: + mapping_key = document._get_mapping_key() + serv = document._connect_sii(mapping_key) + doc_vals["sii_content_sent"] = json.dumps(inv_dict, indent=4) + if mapping_key in ["out_invoice", "out_refund"]: + res = serv.SuministroLRFacturasEmitidas(header, inv_dict) + elif mapping_key in ["in_invoice", "in_refund"]: + res = serv.SuministroLRFacturasRecibidas(header, inv_dict) + # TODO Facturas intracomunitarias 66 RIVA + # elif invoice.fiscal_position_id.id == self.env.ref( + # 'account.fp_intra').id: + # res = serv.SuministroLRDetOperacionIntracomunitaria( + # header, invoices) + res_line = res["RespuestaLinea"][0] + if res["EstadoEnvio"] == "Correcto": + doc_vals.update( + { + "sii_state": "sent", + "sii_csv": res["CSV"], + "sii_send_failed": False, + } + ) + elif ( + res["EstadoEnvio"] == "ParcialmenteCorrecto" + and res_line["EstadoRegistro"] == "AceptadoConErrores" + ): + doc_vals.update( + { + "sii_state": "sent_w_errors", + "sii_csv": res["CSV"], + "sii_send_failed": True, + } + ) + else: + doc_vals["sii_send_failed"] = True + if ( + "sii_state" in doc_vals + and not document.sii_account_registration_date + and mapping_key[:2] == "in" + ): + doc_vals[ + "sii_account_registration_date" + ] = self._get_account_registration_date() + doc_vals["sii_return"] = res + send_error = False + if res_line["CodigoErrorRegistro"]: + send_error = "{} | {}".format( + str(res_line["CodigoErrorRegistro"]), + str(res_line["DescripcionErrorRegistro"])[:60], + ) + doc_vals["sii_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( + { + "sii_send_failed": True, + "sii_send_error": repr(fault)[:60], + "sii_return": repr(fault), + "sii_content_sent": json.dumps(inv_dict, indent=4), + } + ) + document.write(doc_vals) + new_cr.commit() + new_cr.close() + raise ValidationError(fault) from fault + + def confirm_one_document(self): + self.sudo()._send_document_to_sii() diff --git a/l10n_es_aeat_sii_oss/models/account_move.py b/l10n_es_aeat_sii_oss/models/account_move.py index 81777d2294f..4b82b9e3498 100644 --- a/l10n_es_aeat_sii_oss/models/account_move.py +++ b/l10n_es_aeat_sii_oss/models/account_move.py @@ -8,9 +8,9 @@ class AccountMove(models.Model): _inherit = "account.move" - def _get_sii_taxes_map(self, codes): + def _get_sii_taxes_map(self, codes, date): """Inject OSS taxes when querying not subjected invoices.""" - taxes = super()._get_sii_taxes_map(codes) + taxes = super()._get_sii_taxes_map(codes, date) if any([x in ["SFENS", "NotIncludedInTotal"] for x in codes]): taxes |= self.env["account.tax"].search( [ diff --git a/l10n_es_dua_sii/models/account_move.py b/l10n_es_dua_sii/models/account_move.py index 2bcf1d4df07..42175480fcf 100644 --- a/l10n_es_dua_sii/models/account_move.py +++ b/l10n_es_dua_sii/models/account_move.py @@ -36,7 +36,9 @@ def _get_dua_fiscal_position_id(self, company): @api.depends("company_id", "fiscal_position_id", "invoice_line_ids.tax_ids") def _compute_dua_invoice(self): for invoice in self: - taxes = invoice._get_sii_taxes_map(["DUA"]) + taxes = invoice._get_sii_taxes_map( + ["DUA"], self._get_document_fiscal_date() + ) invoice.sii_dua_invoice = invoice.invoice_line_ids.filtered( lambda x: any([tax in taxes for tax in x.tax_ids]) ) diff --git a/l10n_es_pos_sii/README.rst b/l10n_es_pos_sii/README.rst new file mode 100644 index 00000000000..dfa15dcb147 --- /dev/null +++ b/l10n_es_pos_sii/README.rst @@ -0,0 +1,94 @@ +=============================== +Envío de pedidos del TPV al SII +=============================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:f3ffd63230f6fca5f2984f509cdc2f0805451f286e8ac252c15a1285dd759189 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/15.0/l10n_es_pos_sii + :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-15-0/l10n-spain-15-0-l10n_es_pos_sii + :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=15.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Envío al SII de pedidos del TPV de forma individual. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +No se requieren pasos adicionales de configuración, ver configuración de los +módulos `l10n_es_pos`, `l10n_es_aeat_sii_oca` y `pos_default_partner`. + +Known issues / Roadmap +====================== + +* Envío de abonos +* Anular envío al SII +* Envío al SII de forma automática por configuración +* Opción de envío de la sesión de forma resumida (desde pedido, hasta pedido) +* Control TaxFree + +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 + +Contributors +~~~~~~~~~~~~ + +* `Aures Tic `_: + + * Almudena de la Puente + * Jose Zambudio + +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_pos_sii/__init__.py b/l10n_es_pos_sii/__init__.py new file mode 100644 index 00000000000..31660d6a965 --- /dev/null +++ b/l10n_es_pos_sii/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/l10n_es_pos_sii/__manifest__.py b/l10n_es_pos_sii/__manifest__.py new file mode 100644 index 00000000000..2c46248736a --- /dev/null +++ b/l10n_es_pos_sii/__manifest__.py @@ -0,0 +1,21 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Envío de pedidos del TPV al SII", + "category": "Sales/Point Of Sale", + "author": "Aures Tic, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/l10n-spain", + "license": "AGPL-3", + "version": "15.0.1.0.0", + "depends": [ + "point_of_sale", + "l10n_es_pos", + "l10n_es_aeat_sii_oca", + "pos_default_partner", + ], + "data": [ + "views/pos_order.xml", + "views/res_company.xml", + ], + "installable": True, +} diff --git a/l10n_es_pos_sii/models/__init__.py b/l10n_es_pos_sii/models/__init__.py new file mode 100644 index 00000000000..6f096534909 --- /dev/null +++ b/l10n_es_pos_sii/models/__init__.py @@ -0,0 +1,5 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import pos_order +from . import pos_session +from . import res_company diff --git a/l10n_es_pos_sii/models/pos_order.py b/l10n_es_pos_sii/models/pos_order.py new file mode 100644 index 00000000000..4973a3ad9b7 --- /dev/null +++ b/l10n_es_pos_sii/models/pos_order.py @@ -0,0 +1,157 @@ +# Copyright 2023 Aures Tic - Almudena de la Puente +# Copyright 2023 Aures Tic - Jose Zambudio +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +SII_VALID_POS_ORDER_STATES = ["done"] + + +class PosOrder(models.Model): + _name = "pos.order" + _inherit = ["pos.order", "sii.mixin"] + + order_jobs_ids = fields.Many2many( + comodel_name="queue.job", + column1="pos_order_id", + column2="job_id", + relation="pos_order_queue_job_rel", + string="Connector Jobs", + copy=False, + ) + + @api.depends("company_id") + def _compute_sii_description(self): + for order in self: + order.sii_description = order.company_id.sii_pos_description + + @api.depends("amount_total") + def _compute_macrodata(self): + return super()._compute_macrodata() + + @api.depends( + "company_id", + "company_id.sii_enabled", + "fiscal_position_id", + "fiscal_position_id.sii_active", + ) + def _compute_sii_enabled(self): + """Compute if the order is enabled for the SII""" + for order in self: + if order.company_id.sii_enabled: + order.sii_enabled = ( + order.fiscal_position_id and order.fiscal_position_id.sii_active + ) or not order.fiscal_position_id + else: + order.sii_enabled = False + + def _is_sii_type_breakdown_required(self, taxes_dict): + """As these are simplified invoices, we don't break taxes. + + El desglose se hará obligatoriamente a nivel de tipo de operación si + cumple las 2 condiciones: + 1- No sea F2-factura simplificada o F4-asiento resumen + 2- La contraparte sea del tipo IDOtro o que sea NIF que empiece por N + """ + return False + + def _get_sii_jobs_field_name(self): + return "order_jobs_ids" + + def _get_valid_document_states(self): + return SII_VALID_POS_ORDER_STATES + + def _sii_get_partner(self): + partner = ( + self.partner_id.commercial_partner_id + or self.session_id.config_id.default_partner_id + ) + if not partner: + raise UserError( + _("You must define a default partner for POS {}").format( + self.session_id.config_id.display_name, + ) + ) + return partner + + def _is_sii_simplified_invoice(self): + return True + + def _get_mapping_key(self): + return "out_invoice" + + def _get_document_date(self): + return self.date_order + + def _get_document_fiscal_date(self): + return self.date_order + + def _get_document_serial_number(self): + return (self.l10n_es_unique_id or self.pos_reference)[0:60] + + def _get_document_product_exempt(self, applied_taxes): + return set( + self.mapped("lines") + .filtered( + lambda x: ( + any(tax in x.tax_ids_after_fiscal_position for tax in applied_taxes) + and x.product_id.sii_exempt_cause + and x.product_id.sii_exempt_cause != "none" + ) + ) + .mapped("product_id.sii_exempt_cause") + ) + + def _get_tax_info(self): + self.ensure_one() + taxes = {} + for line in self.lines: + if not line.tax_ids_after_fiscal_position: + continue + line_taxes = line.tax_ids_after_fiscal_position.sudo().compute_all( + line.price_unit * (1 - (line.discount or 0.0) / 100.0), + line.order_id.pricelist_id.currency_id or self.session_id.currency_id, + line.qty, + product=line.product_id, + partner=line.order_id.partner_id or False, + ) + for line_tax in line_taxes["taxes"]: + tax = self.env["account.tax"].browse(line_tax["id"]) + taxes.setdefault( + tax, + {"tax": tax, "amount": 0.0, "base": 0.0}, + ) + taxes[tax]["amount"] += line_tax["amount"] + taxes[tax]["base"] += line_tax["base"] + return taxes + + def _get_sii_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() + taxes_req = self._get_sii_taxes_map(["RE"], self._get_document_fiscal_date()) + re_lines = self.lines.filtered( + lambda x: tax in x.tax_ids_after_fiscal_position + and x.tax_ids_after_fiscal_position & taxes_req + ) + req_tax = re_lines.mapped("tax_ids_after_fiscal_position") & taxes_req + if len(req_tax) > 1: + raise UserError(_("There's a mismatch in taxes for RE. Check them.")) + return req_tax + + def _get_document_amount_total(self): + return self.amount_total + + def _get_sii_invoice_type(self): + return "R5" if self.amount_total < 0.0 else "F2" + + def _get_sii_invoice_dict_out(self, cancel=False): + inv_dict = super()._get_sii_invoice_dict_out(cancel=cancel) + if self.amount_total < 0.0: + inv_dict["FacturaExpedida"]["TipoRectificativa"] = "I" + return inv_dict diff --git a/l10n_es_pos_sii/models/pos_session.py b/l10n_es_pos_sii/models/pos_session.py new file mode 100644 index 00000000000..13129d188af --- /dev/null +++ b/l10n_es_pos_sii/models/pos_session.py @@ -0,0 +1,19 @@ +from odoo import models + + +class PosSession(models.Model): + _inherit = "pos.session" + + def _validate_session( + self, + balancing_account=False, + amount_to_balance=0, + bank_payment_method_diffs=None, + ): + res = super()._validate_session( + balancing_account=balancing_account, + amount_to_balance=amount_to_balance, + bank_payment_method_diffs=bank_payment_method_diffs, + ) + self.order_ids.send_sii() + return res diff --git a/l10n_es_pos_sii/models/res_company.py b/l10n_es_pos_sii/models/res_company.py new file mode 100644 index 00000000000..5ad9ecf79c3 --- /dev/null +++ b/l10n_es_pos_sii/models/res_company.py @@ -0,0 +1,16 @@ +# Copyright 2023 Aures Tic - Almudena de la Puente +# Copyright 2023 Aures Tic - Jose Zambudio +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + sii_pos_description = fields.Char( + string="SII POS Description", + size=500, + default="/", + help="The description for pos orders.", + ) diff --git a/l10n_es_pos_sii/readme/CONFIGURE.rst b/l10n_es_pos_sii/readme/CONFIGURE.rst new file mode 100644 index 00000000000..6b913028f04 --- /dev/null +++ b/l10n_es_pos_sii/readme/CONFIGURE.rst @@ -0,0 +1,2 @@ +No se requieren pasos adicionales de configuración, ver configuración de los +módulos `l10n_es_pos`, `l10n_es_aeat_sii_oca` y `pos_default_partner`. diff --git a/l10n_es_pos_sii/readme/CONTRIBUTORS.rst b/l10n_es_pos_sii/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..a8441fbfeb2 --- /dev/null +++ b/l10n_es_pos_sii/readme/CONTRIBUTORS.rst @@ -0,0 +1,4 @@ +* `Aures Tic `_: + + * Almudena de la Puente + * Jose Zambudio diff --git a/l10n_es_pos_sii/readme/DESCRIPTION.rst b/l10n_es_pos_sii/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..d621d521024 --- /dev/null +++ b/l10n_es_pos_sii/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Envío al SII de pedidos del TPV de forma individual. diff --git a/l10n_es_pos_sii/readme/ROADMAP.rst b/l10n_es_pos_sii/readme/ROADMAP.rst new file mode 100644 index 00000000000..f66a63b6d79 --- /dev/null +++ b/l10n_es_pos_sii/readme/ROADMAP.rst @@ -0,0 +1,2 @@ +* Anular envío al SII +* Opción de envío de la sesión de forma resumida (desde pedido, hasta pedido) diff --git a/l10n_es_pos_sii/static/description/index.html b/l10n_es_pos_sii/static/description/index.html new file mode 100644 index 00000000000..f23e4131566 --- /dev/null +++ b/l10n_es_pos_sii/static/description/index.html @@ -0,0 +1,442 @@ + + + + + + +Envío de pedidos del TPV al SII + + + +
+

Envío de pedidos del TPV al SII

+ + +

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

+

Envío al SII de pedidos del TPV de forma individual.

+

Table of contents

+ +
+

Configuration

+

No se requieren pasos adicionales de configuración, ver configuración de los +módulos l10n_es_pos, l10n_es_aeat_sii_oca y pos_default_partner.

+
+
+

Known issues / Roadmap

+
    +
  • Envío de abonos
  • +
  • Anular envío al SII
  • +
  • Envío al SII de forma automática por configuración
  • +
  • Opción de envío de la sesión de forma resumida (desde pedido, hasta pedido)
  • +
  • Control TaxFree
  • +
+
+
+

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

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_pos_sii/tests/__init__.py b/l10n_es_pos_sii/tests/__init__.py new file mode 100644 index 00000000000..baf97f9367d --- /dev/null +++ b/l10n_es_pos_sii/tests/__init__.py @@ -0,0 +1 @@ +from . import test_l10n_es_pos_sii diff --git a/l10n_es_pos_sii/tests/json/sii_pos_order_iva10b.json b/l10n_es_pos_sii/tests/json/sii_pos_order_iva10b.json new file mode 100644 index 00000000000..08b4b36d7b8 --- /dev/null +++ b/l10n_es_pos_sii/tests/json/sii_pos_order_iva10b.json @@ -0,0 +1,37 @@ +{ + "IDFactura": { + "IDEmisorFactura": { + "NIF": "U2687761C" + }, + "NumSerieFacturaEmisor": "Shop0002", + "FechaExpedicionFacturaEmisor": "14-06-2023" + }, + "PeriodoLiquidacion": { + "Ejercicio": 2023, + "Periodo": "06" + }, + "FacturaExpedida": { + "TipoFactura": "F2", + "ClaveRegimenEspecialOTrascendencia": "01", + "DescripcionOperacion": "/", + "TipoDesglose": { + "DesgloseFactura": { + "Sujeta": { + "NoExenta": { + "TipoNoExenta": "S1", + "DesgloseIVA": { + "DetalleIVA": [ + { + "TipoImpositivo": "10.0", + "BaseImponible": 100.0, + "CuotaRepercutida": 10.0 + } + ] + } + } + } + } + }, + "ImporteTotal": 110.0 + } +} diff --git a/l10n_es_pos_sii/tests/json/sii_pos_order_iva21b.json b/l10n_es_pos_sii/tests/json/sii_pos_order_iva21b.json new file mode 100644 index 00000000000..22ee884d22b --- /dev/null +++ b/l10n_es_pos_sii/tests/json/sii_pos_order_iva21b.json @@ -0,0 +1,37 @@ +{ + "IDFactura": { + "IDEmisorFactura": { + "NIF": "U2687761C" + }, + "NumSerieFacturaEmisor": "Shop0001", + "FechaExpedicionFacturaEmisor": "14-06-2023" + }, + "PeriodoLiquidacion": { + "Ejercicio": 2023, + "Periodo": "06" + }, + "FacturaExpedida": { + "TipoFactura": "F2", + "ClaveRegimenEspecialOTrascendencia": "01", + "DescripcionOperacion": "/", + "TipoDesglose": { + "DesgloseFactura": { + "Sujeta": { + "NoExenta": { + "TipoNoExenta": "S1", + "DesgloseIVA": { + "DetalleIVA": [ + { + "TipoImpositivo": "21.0", + "BaseImponible": 100.0, + "CuotaRepercutida": 21.0 + } + ] + } + } + } + } + }, + "ImporteTotal": 121.0 + } +} diff --git a/l10n_es_pos_sii/tests/json/sii_pos_order_iva21b_iva10b.json b/l10n_es_pos_sii/tests/json/sii_pos_order_iva21b_iva10b.json new file mode 100644 index 00000000000..a6d082d692d --- /dev/null +++ b/l10n_es_pos_sii/tests/json/sii_pos_order_iva21b_iva10b.json @@ -0,0 +1,42 @@ +{ + "IDFactura": { + "IDEmisorFactura": { + "NIF": "U2687761C" + }, + "NumSerieFacturaEmisor": "Shop0003", + "FechaExpedicionFacturaEmisor": "14-06-2023" + }, + "PeriodoLiquidacion": { + "Ejercicio": 2023, + "Periodo": "06" + }, + "FacturaExpedida": { + "TipoFactura": "F2", + "ClaveRegimenEspecialOTrascendencia": "01", + "DescripcionOperacion": "/", + "TipoDesglose": { + "DesgloseFactura": { + "Sujeta": { + "NoExenta": { + "TipoNoExenta": "S1", + "DesgloseIVA": { + "DetalleIVA": [ + { + "BaseImponible": 100.0, + "CuotaRepercutida": 21.0, + "TipoImpositivo": "21.0" + }, + { + "BaseImponible": 100.0, + "CuotaRepercutida": 10.0, + "TipoImpositivo": "10.0" + } + ] + } + } + } + } + }, + "ImporteTotal": 231.0 + } +} diff --git a/l10n_es_pos_sii/tests/json/sii_pos_order_refund_iva21b.json b/l10n_es_pos_sii/tests/json/sii_pos_order_refund_iva21b.json new file mode 100644 index 00000000000..cfd746412ce --- /dev/null +++ b/l10n_es_pos_sii/tests/json/sii_pos_order_refund_iva21b.json @@ -0,0 +1,38 @@ +{ + "IDFactura": { + "IDEmisorFactura": { + "NIF": "U2687761C" + }, + "NumSerieFacturaEmisor": "Shop0004", + "FechaExpedicionFacturaEmisor": "14-06-2023" + }, + "PeriodoLiquidacion": { + "Ejercicio": 2023, + "Periodo": "06" + }, + "FacturaExpedida": { + "TipoFactura": "R5", + "ClaveRegimenEspecialOTrascendencia": "01", + "DescripcionOperacion": "/", + "TipoDesglose": { + "DesgloseFactura": { + "Sujeta": { + "NoExenta": { + "TipoNoExenta": "S1", + "DesgloseIVA": { + "DetalleIVA": [ + { + "TipoImpositivo": "21.0", + "BaseImponible": -100.0, + "CuotaRepercutida": -21.0 + } + ] + } + } + } + } + }, + "ImporteTotal": -121.0, + "TipoRectificativa": "I" + } +} diff --git a/l10n_es_pos_sii/tests/test_l10n_es_pos_sii.py b/l10n_es_pos_sii/tests/test_l10n_es_pos_sii.py new file mode 100644 index 00000000000..03d6bb16b26 --- /dev/null +++ b/l10n_es_pos_sii/tests/test_l10n_es_pos_sii.py @@ -0,0 +1,306 @@ +# Copyright 2023 Aures Tic - Almudena de la Puente +# Copyright 2023 Aures Tic - Jose Zambudio +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +import json + +from odoo.modules.module import get_resource_path +from odoo.tests import tagged + +from odoo.addons.l10n_es_aeat_sii_oca.tests.test_l10n_es_aeat_sii import ( + TestL10nEsAeatSiiBase, +) +from odoo.addons.point_of_sale.tests.common import TestPoSCommon + + +@tagged("post_install", "-at_install") +class TestSpainPosSii(TestPoSCommon, TestL10nEsAeatSiiBase): + @classmethod + def setUpClass(cls, chart_template_ref=None): + chart_template_ref = ( + "l10n_es.account_chart_template_common" or chart_template_ref + ) + super().setUpClass(chart_template_ref=chart_template_ref) + # !: AccountTestInvoicingCommon overwrite user.company_id with sii disabled + cls.company = cls.env.user.company_id + cls.company.write( + { + "sii_enabled": True, + "sii_test": True, + "use_connector": True, + "sii_method": "manual", + "vat": "ESU2687761C", + "sii_description_method": "manual", + "tax_agency_id": cls.env.ref("l10n_es_aeat.aeat_tax_agency_spain"), + } + ) + cls.customer.write( + { + "country_id": cls.env.ref("base.es").id, + "vat": "F35999705", + } + ) + cls.fr_country = cls.env.ref("base.fr") + cls.other_customer.write( + { + "country_id": cls.fr_country.id, + "vat": "FR82542065479", + } + ) + + def setUp(self): + super(TestSpainPosSii, self).setUp() + self.PosSession = self.env["pos.session"] + self.config = self.basic_config + self.config.write( + { + "iface_l10n_es_simplified_invoice": True, + "company_id": self.company.id, + "default_partner_id": self.env["res.partner"] + .create( + { + "name": "Test simplified default customer", + "sii_simplified_invoice": True, + } + ) + .id, + } + ) + self.env.user.write( + {"groups_id": [(3, self.env.ref("account.group_account_manager").id)]} + ) + self.tax_21b = self.env.ref( + f"l10n_es.{self.env.user.company_id.id}_account_tax_template_s_iva21b" + ) + self.tax_account = self.env.ref( + f"l10n_es.{self.env.user.company_id.id}_account_common_477" + ) + self.tax_10b = self.env.ref( + f"l10n_es.{self.env.user.company_id.id}_account_tax_template_s_iva10b" + ) + self.product21 = self.create_product( + "Product 21b", + self.categ_basic, + 100.0, + 100.0, + tax_ids=self.tax_21b.ids, + ) + self.product10 = self.create_product( + "Product 10b", + self.categ_basic, + 100.0, + 100.0, + tax_ids=self.tax_10b.ids, + ) + self._create_session_closed() + self.session = self.PosSession.search([], limit=1, order="id desc") + self.order = self.session.order_ids[:1] + + def _create_session_closed(self): + cash = self.cash_pm1 + self._run_test( + { + "payment_methods": cash, + "orders": [ + { + "pos_order_lines_ui_args": [(self.product21, 1)], + "payments": [(cash, 121)], + "customer": False, + "is_invoiced": False, + "uid": "00100-010-0001", + }, + { + "pos_order_lines_ui_args": [(self.product10, 1)], + "payments": [(cash, 110)], + "customer": self.other_customer, + "is_invoiced": False, + "uid": "00100-010-0002", + }, + { + "pos_order_lines_ui_args": [ + (self.product21, 1), + (self.product10, 1), + ], + "payments": [(cash, 231)], + "customer": self.customer, + "is_invoiced": False, + "uid": "00100-010-0003", + }, + ], + "journal_entries_before_closing": {}, + "journal_entries_after_closing": { + "session_journal_entry": { + "line_ids": [ + { + "account_id": self.tax_account.id, + "partner_id": False, + "debit": 0, + "credit": 42, + "reconciled": False, + }, + { + "account_id": self.tax_account.id, + "partner_id": False, + "debit": 0, + "credit": 20, + "reconciled": False, + }, + { + "account_id": self.sales_account.id, + "partner_id": False, + "debit": 0, + "credit": 200, + "reconciled": False, + }, + { + "account_id": self.sales_account.id, + "partner_id": False, + "debit": 0, + "credit": 200, + "reconciled": False, + }, + { + "account_id": cash.receivable_account_id.id, + "partner_id": False, + "debit": 462, + "credit": 0, + "reconciled": True, + }, + ], + }, + "cash_statement": [ + ( + (462,), + { + "line_ids": [ + { + "account_id": cash.journal_id.default_account_id.id, + "partner_id": False, + "debit": 462, + "credit": 0, + "reconciled": False, + }, + { + "account_id": cash.receivable_account_id.id, + "partner_id": False, + "debit": 0, + "credit": 462, + "reconciled": True, + }, + ] + }, + ) + ], + "bank_payments": [], + }, + } + ) + + def _compare_sii_dict(self, json_file, order): + """Helper method for comparing the expected SII dict with .""" + module = "l10n_es_pos_sii" + result_dict = order._get_sii_invoice_dict() + 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 order + + def test_01_partner_sii_enabled(self): + company_02 = self.env["res.company"].create({"name": "Company 02"}) + self.env.user.company_ids += company_02 + self.assertTrue(self.partner.sii_enabled) + self.partner.company_id = company_02 + self.assertFalse(self.partner.sii_enabled) + + def test_02_json_orders(self): + json_by_taxes = { + self.tax_21b: { + "json": "sii_pos_order_iva21b.json", + "name": "Shop0001", + }, + self.tax_10b: { + "json": "sii_pos_order_iva10b.json", + "name": "Shop0002", + }, + (self.tax_10b + self.tax_21b): { + "json": "sii_pos_order_iva21b_iva10b.json", + "name": "Shop0003", + }, + } + for order in self.session.order_ids: + taxes = order.lines.mapped("tax_ids_after_fiscal_position").sorted( + key=lambda tax: tax.amount + ) + order.write( + { + "l10n_es_unique_id": json_by_taxes.get(taxes, {}).get("name"), + "date_order": "2023-06-14", + } + ) + order.send_sii() + self._compare_sii_dict(json_by_taxes.get(taxes, {}).get("json"), order) + + def test_03_job_creation(self): + for order in self.session.order_ids: + order.send_sii() + self.assertTrue(order.order_jobs_ids) + + def test_04_is_sii_simplified_invoice(self): + for order in self.session.order_ids: + self.assertTrue(order._is_sii_simplified_invoice()) + + def test_05_sii_description(self): + company = self.order.company_id + self.assertEqual(self.order.sii_description, "/") + company.write( + { + "sii_pos_description": "Test POS description", + } + ) + self.order._compute_sii_description() + self.assertEqual(self.order.sii_description, "Test POS description") + + def test_06_refund_sii_refund_type(self): + cash = self.cash_pm1 + self._start_pos_session(cash, 462.0) + refund_order = self._create_orders( + [ + { + "pos_order_lines_ui_args": [(self.product21, -1)], + "payments": [(cash, -121)], + "customer": False, + "is_invoiced": False, + "uid": "00100-010-0004", + }, + ] + ).get("00100-010-0004") + refund_order.write( + { + "l10n_es_unique_id": "Shop0004", + "date_order": "2023-06-14", + } + ) + self._compare_sii_dict("sii_pos_order_refund_iva21b.json", refund_order) + + def test_07_automatic_send(self): + self.company.sii_description_method = "auto" + cash = self.cash_pm1 + pos_session = self._start_pos_session(cash, 462.0) + self._create_orders( + [ + { + "pos_order_lines_ui_args": [(self.product21, 1)], + "payments": [(cash, 121)], + "customer": False, + "is_invoiced": False, + "uid": "00100-010-0004", + }, + ] + ) + pos_session.post_closing_cash_details(583.0) + pos_session.close_session_from_ui() + for order in pos_session.order_ids: + self.assertTrue(order.order_jobs_ids) diff --git a/l10n_es_pos_sii/views/pos_order.xml b/l10n_es_pos_sii/views/pos_order.xml new file mode 100644 index 00000000000..63a4d211186 --- /dev/null +++ b/l10n_es_pos_sii/views/pos_order.xml @@ -0,0 +1,91 @@ + + + + + + pos.order.view.form.inherit + pos.order + + + +