diff --git a/addons/account_edi/models/account_move.py b/addons/account_edi/models/account_move.py index c868b32b2d8..7d7d3ae17eb 100644 --- a/addons/account_edi/models/account_move.py +++ b/addons/account_edi/models/account_move.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from collections import defaultdict + from odoo import api, fields, models, _ from odoo.exceptions import UserError @@ -139,11 +140,9 @@ class AccountMove(models.Model): # Add to global results. results['tax_amount'] += tax_values['tax_amount'] results['tax_amount_currency'] += tax_values['tax_amount_currency'] - # Add to tax details. if serialized_grouping_key not in results['tax_details']: tax_details = results['tax_details'][serialized_grouping_key] - tax_details.update(grouping_key) tax_details.update({ 'base_amount': tax_values['base_amount'], @@ -154,12 +153,11 @@ class AccountMove(models.Model): if key_by_tax[tax_values['tax_id']] != key_by_tax.get(tax_values['src_line_id'].tax_line_id): tax_details['base_amount'] += tax_values['base_amount'] tax_details['base_amount_currency'] += tax_values['base_amount_currency'] - tax_details['tax_amount'] += tax_values['tax_amount'] tax_details['tax_amount_currency'] += tax_values['tax_amount_currency'] tax_details['group_tax_details'].append(tax_values) - def _prepare_edi_tax_details(self, filter_to_apply=None, grouping_key_generator=None): + def _prepare_edi_tax_details(self, filter_to_apply=None, filter_invl_to_apply=None, grouping_key_generator=None): ''' Compute amounts related to taxes for the current invoice. :param filter_to_apply: Optional filter to exclude some tax values from the final results. @@ -178,6 +176,8 @@ class AccountMove(models.Model): If the filter is returning False, it means the current tax values will be ignored when computing the final results. + :param filter_invl_to_apply: Optional filter to exclude some invoice lines. + :param grouping_key_generator: Optional method used to group tax values together. By default, the tax values are grouped by tax. This parameter is a method getting a dictionary as parameter (same signature as 'filter_to_apply'). @@ -228,6 +228,9 @@ class AccountMove(models.Model): # Compute the taxes values for each invoice line. invoice_lines = self.invoice_line_ids.filtered(lambda line: not line.display_type) + if filter_invl_to_apply: + invoice_lines = invoice_lines.filtered(filter_invl_to_apply) + invoice_lines_tax_values_dict = defaultdict(list) domain = [('move_id', '=', self.id)] @@ -256,11 +259,50 @@ class AccountMove(models.Model): # Apply 'filter_to_apply'. - if filter_to_apply: - invoice_lines_tax_values_dict = { - invoice_line: [x for x in tax_values_list if filter_to_apply(x)] - for invoice_line, tax_values_list in invoice_lines_tax_values_dict.items() - } + if self.move_type in ('out_refund', 'in_refund'): + tax_rep_lines_field = 'refund_repartition_line_ids' + else: + tax_rep_lines_field = 'invoice_repartition_line_ids' + + filtered_invoice_lines_tax_values_dict = {} + for invoice_line in invoice_lines: + tax_values_list = invoice_lines_tax_values_dict.get(invoice_line, []) + filtered_invoice_lines_tax_values_dict[invoice_line] = [] + + # Search for unhandled taxes. + taxes_set = set(invoice_line.tax_ids.flatten_taxes_hierarchy()) + for tax_values in tax_values_list: + taxes_set.discard(tax_values['tax_id']) + + if not filter_to_apply or filter_to_apply(tax_values): + filtered_invoice_lines_tax_values_dict[invoice_line].append(tax_values) + + # Restore zero-tax tax details. + for zero_tax in taxes_set: + + affect_base_amount = 0.0 + affect_base_amount_currency = 0.0 + for tax_values in tax_values_list: + if zero_tax in tax_values['tax_line_id'].tax_ids: + affect_base_amount += tax_values['tax_amount'] + affect_base_amount_currency += tax_values['tax_amount_currency'] + + for tax_rep in zero_tax[tax_rep_lines_field].filtered(lambda x: x.repartition_type == 'tax'): + tax_values = { + 'base_line_id': invoice_line, + 'tax_line_id': self.env['account.move.line'], + 'src_line_id': invoice_line, + 'tax_id': zero_tax, + 'src_tax_id': zero_tax, + 'tax_repartition_line_id': tax_rep, + 'base_amount': invoice_line.balance + affect_base_amount, + 'tax_amount': 0.0, + 'base_amount_currency': invoice_line.amount_currency + affect_base_amount_currency, + 'tax_amount_currency': 0.0, + } + + if not filter_to_apply or filter_to_apply(tax_values): + filtered_invoice_lines_tax_values_dict[invoice_line].append(tax_values) # Initialize the results dict. @@ -294,7 +336,7 @@ class AccountMove(models.Model): # Apply 'grouping_key_generator' to 'invoice_lines_tax_values_list' and add all values to the final results. for invoice_line in invoice_lines: - tax_values_list = invoice_lines_tax_values_dict.get(invoice_line, []) + tax_values_list = filtered_invoice_lines_tax_values_dict[invoice_line] key_by_tax = {} diff --git a/addons/l10n_es_edi_sii/__init__.py b/addons/l10n_es_edi_sii/__init__.py new file mode 100644 index 00000000000..5e6da38df55 --- /dev/null +++ b/addons/l10n_es_edi_sii/__init__.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import models +from odoo import api, SUPERUSER_ID + + +def _l10n_es_edi_post_init(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + companies = env['res.company'].search([('partner_id.country_id.code', '=', 'ES')]) + + all_chart_templates = companies.chart_template_id + current_chart_template = all_chart_templates + while current_chart_template.parent_id: + all_chart_templates |= current_chart_template.parent_id + current_chart_template = current_chart_template.parent_id + + if all_chart_templates: + tax_templates = env['account.tax.template'].search([ + ('chart_template_id', 'in', all_chart_templates.ids), + '|', '|', + ('l10n_es_type', '!=', False), + ('l10n_es_exempt_reason', '!=', False), + ('tax_scope', '!=', False), + ]) + xml_ids = tax_templates.get_external_id() + for company in companies: + for tax_template in tax_templates: + module, xml_id = xml_ids.get(tax_template.id).split('.') + tax = env.ref('%s.%s_%s' % (module, company.id, xml_id), raise_if_not_found=False) + if tax: + tax.write({ + 'l10n_es_exempt_reason': tax_template.l10n_es_exempt_reason, + 'tax_scope': tax_template.tax_scope, + 'l10n_es_type': tax_template.l10n_es_type, + }) diff --git a/addons/l10n_es_edi_sii/__manifest__.py b/addons/l10n_es_edi_sii/__manifest__.py new file mode 100644 index 00000000000..5952f5e0f87 --- /dev/null +++ b/addons/l10n_es_edi_sii/__manifest__.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +# Thanks to AEOdoo and the Spanish community +# Specially among others Ignacio Ibeas, Pedro Baeza and Landoo + +{ + 'name': "Spain - SII EDI Suministro de Libros", + 'version': '1.0', + 'category': 'Accounting/Localizations/EDI', + 'description': """ + This module sends the taxes information (mostly VAT) of the + vendor bills and customer invoices to the SII. It is called + Procedimiento G417 - IVA. Llevanza de libros registro. It is + required for every company with a turnover of +6M€ and others can + already make use of it. The invoices are automatically + sent after validation. + + How the information is sent to the SII depends on the + configuration that is put in the taxes. The taxes + that were in the chart template (l10n_es) are automatically + configured to have the right type. It is possible however + that extra taxes need to be created for certain exempt/no sujeta reasons. + + You need to configure your certificate and the tax agency. + """, + 'depends': [ + 'l10n_es', + 'account_edi', + ], + 'data': [ + 'data/account_tax_data.xml', + 'data/account_edi_data.xml', + 'data/res_partner_data.xml', + + 'security/ir.model.access.csv', + + 'views/account_tax_views.xml', + 'views/l10n_es_edi_certificate_views.xml', + 'views/res_config_settings_views.xml', + ], + 'demo': [ + 'demo/demo_company.xml' + ], + 'external_dependencies': { + 'python': ['pyOpenSSL'], + }, + 'post_init_hook': '_l10n_es_edi_post_init', + 'license': 'LGPL-3', +} diff --git a/addons/l10n_es_edi_sii/data/account_edi_data.xml b/addons/l10n_es_edi_sii/data/account_edi_data.xml new file mode 100644 index 00000000000..57cdbc5aa89 --- /dev/null +++ b/addons/l10n_es_edi_sii/data/account_edi_data.xml @@ -0,0 +1,11 @@ + + + + + + SII IVA Llevanza de libros registro (ES) + es_sii + + + + diff --git a/addons/l10n_es_edi_sii/data/account_tax_data.xml b/addons/l10n_es_edi_sii/data/account_tax_data.xml new file mode 100644 index 00000000000..84e86400b2c --- /dev/null +++ b/addons/l10n_es_edi_sii/data/account_tax_data.xml @@ -0,0 +1,418 @@ + + + + sujeto + consu + + + sujeto + service + + + sujeto_isp + + + 21% IVA soportado (bienes corrientes) + sujeto + consu + + + 21% IVA soportado (servicios corrientes) + sujeto + service + + + IVA 21% Adquisición de servicios intracomunitarios + sujeto + service + + + IVA 21% Adquisición Intracomunitaria. Bienes corrientes + sujeto + consu + + + IVA 21% Adquisición Intracomunitaria. Bienes de inversión + sujeto + consu + + + IVA 21% Importaciones bienes corrientes + sujeto + consu + + + IVA 21% Importaciones bienes de inversión + sujeto + consu + + + Retenciones IRPF (Trabajadores) dinerarios + retencion + + + IVA 4% Adquisición de servicios extracomunitarios + sujeto_isp + consu + + + IVA 10% Adquisición de servicios extracomunitarios + sujeto_isp + consu + + + IVA 21% Adquisición de servicios extracomunitarios + sujeto_isp + service + + + IVA 4% Adquisición Intracomunitario. Bienes corrientes + sujeto + consu + + + IVA 4% Adquisición Intracomunitario. Bienes de inversión + sujeto + consu + + + IVA 10% Adquisición Intracomunitario. Bienes corrientes + sujeto + consu + + + IVA 10% Adquisición Intracomunitario. Bienes de inversión + sujeto + consu + + + IVA 0% Prestación de servicios intracomunitario + no_sujeto + service + + + No sujeto Repercutido (Servicios) + no_sujeto + service + + + No sujeto Repercutido (Bienes) + no_sujeto + consu + + + IVA 0% Prestación de servicios extracomunitaria + no_sujeto + service + + + IVA 4% Importaciones bienes corrientes + sujeto + consu + + + IVA 4% Importaciones bienes de inversión + sujeto + consu + + + IVA 10% Importaciones bienes corrientes + sujeto + consu + + + IVA 10% Importaciones bienes de inversión + sujeto + consu + + + 4% IVA Soportado (bienes de inversión) + sujeto + consu + True + + + 4% IVA soportado (servicios corrientes) + sujeto + consu + + + 10% IVA Soportado (bienes de inversión) + sujeto + consu + True + + + 21% IVA Soportado (bienes de inversión) + sujeto + consu + True + + + 10% IVA soportado (bienes corrientes) + sujeto + consu + + + 4% IVA soportado (bienes corrientes) + sujeto + consu + + + 10% IVA soportado (servicios corrientes) + sujeto + consu + + + IVA Exento Repercutido Sujeto + exento + E1 + + + IVA Exento Repercutido No Sujeto + ignore + + + 0.50% Recargo Equivalencia Ventas + recargo + + + IVA 4% (Bienes) + sujeto + consu + + + IVA 10% (Bienes) + sujeto + consu + + + 21% IVA Soportado no deducible + no_deducible + + + 10% IVA Soportado no deducible + no_deducible + + + 4% IVA Soportado no deducible + no_deducible + + + IVA 4% (Servicios) + sujeto + service + + + IVA 10% (Servicios) + sujeto + service + + + 1.4% Recargo Equivalencia Ventas + recargo + + + 5.2% Recargo Equivalencia Ventas + recargo + + + IVA Soportado exento (operaciones corrientes) + sujeto + consu + + + IVA Soportado no sujeto (Servicios) + no_sujeto + service + + + IVA Soportado no sujeto (Bienes) + no_sujeto + consu + + + Retenciones a cuenta IRPF 9% + retencion + + + Retenciones a cuenta IRPF 18% + retencion + + + Retenciones a cuenta IRPF 19% + retencion + + + Retenciones a cuenta 19% (Arrendamientos) + retencion + + + Retenciones a cuenta 19,5% (Arrendamientos) + retencion + + + Retenciones IRPF 19% + retencion + + + Retenciones 20% (Arrendamientos) + retencion + + + Retenciones IRPF 18% + retencion + + + Retenciones 19% (Arrendamientos) + retencion + + + Retenciones 19,5% (Arrendamientos) + retencion + + + Retenciones IRPF 7% + retencion + + + Retenciones IRPF 9% + retencion + + + Retenciones IRPF 14% + retencion + + + Retenciones a cuenta IRPF 20% + retencion + + + Retenciones a cuenta 20% (Arrendamientos) + retencion + + + Retenciones a cuenta IRPF 24% + retencion + + + 12% IVA Soportado régimen agricultura + sujeto_agricultura + + + 10,5% IVA Soportado régimen ganadero o pesca + + + + IVA 0% Exportaciones + exento + E2 + consu + + + IVA 0% Entregas Intracomunitarias exentas + exento + E5 + consu + + + 1.4% Recargo Equivalencia Compras + recargo + + + 0.50% Recargo Equivalencia Compras + recargo + + + 5.2% Recargo Equivalencia Compras + recargo + + + Retenciones a cuenta IRPF 1% + retencion + + + Retenciones a cuenta IRPF 2% + retencion + + + Retenciones a cuenta IRPF 21% + retencion + + + Retenciones a cuenta 21% (Arrendamientos) + retencion + + + Retenciones a cuenta IRPF 7% + retencion + + + Retenciones a cuenta IRPF 15% + retencion + + + Retenciones IRPF 1% + retencion + + + Retenciones IRPF 15% + retencion + + + Retenciones IRPF (Trabajadores) + retencion + + + IVA 10% Adquisición de servicios intracomunitarios + retencion + + + IVA 4% Adquisición de servicios intracomunitarios + retencion + + + Retenciones IRPF (Trabajadores) en especie + retencion + + + Retenciones IRPF 20% + retencion + + + Retenciones 21% (Arrendamientos) + retencion + + + Retenciones IRPF 21% + retencion + + + Retenciones IRPF 2% + retencion + + + IVA 0% Venta con Inversión del Sujeto Pasivo + sujeto_isp + + + IVA 4% Compra con Inversión del Sujeto Pasivo Nacional + sujeto_isp + + + IVA 10% Compra con Inversión del Sujeto Pasivo Nacional + sujeto_isp + + + IVA 21% Compra con Inversión del Sujeto Pasivo Nacional + sujeto_isp + + + Retenciones 19% (préstamos) + retencion + + + Retenciones 19% (reparto de dividendos) + retencion + + diff --git a/addons/l10n_es_edi_sii/data/res_partner_data.xml b/addons/l10n_es_edi_sii/data/res_partner_data.xml new file mode 100644 index 00000000000..9445f9b0348 --- /dev/null +++ b/addons/l10n_es_edi_sii/data/res_partner_data.xml @@ -0,0 +1,6 @@ + + + + Simplified Invoice Partner (ES) + + diff --git a/addons/l10n_es_edi_sii/demo/certificates/sello_entidad_act.p12 b/addons/l10n_es_edi_sii/demo/certificates/sello_entidad_act.p12 new file mode 100644 index 00000000000..ad2d5239630 Binary files /dev/null and b/addons/l10n_es_edi_sii/demo/certificates/sello_entidad_act.p12 differ diff --git a/addons/l10n_es_edi_sii/demo/demo_company.xml b/addons/l10n_es_edi_sii/demo/demo_company.xml new file mode 100644 index 00000000000..7018d65874c --- /dev/null +++ b/addons/l10n_es_edi_sii/demo/demo_company.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addons/l10n_es_edi_sii/models/__init__.py b/addons/l10n_es_edi_sii/models/__init__.py new file mode 100644 index 00000000000..ae321c40704 --- /dev/null +++ b/addons/l10n_es_edi_sii/models/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import account_edi_format +from . import account_move +from . import account_tax +from . import l10n_es_edi_certificate +from . import res_company +from . import res_config_settings diff --git a/addons/l10n_es_edi_sii/models/account_edi_format.py b/addons/l10n_es_edi_sii/models/account_edi_format.py new file mode 100644 index 00000000000..5519706629a --- /dev/null +++ b/addons/l10n_es_edi_sii/models/account_edi_format.py @@ -0,0 +1,666 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from collections import defaultdict +from urllib3.util.ssl_ import create_urllib3_context, DEFAULT_CIPHERS +from OpenSSL.crypto import load_certificate, load_privatekey, FILETYPE_PEM +from zeep.transports import Transport + +from odoo import fields, models, _ +from odoo.tools import html_escape + +import math +import json +import requests +import zeep + + +# Custom patches to perform the WSDL requests. +EUSKADI_CIPHERS = f"{DEFAULT_CIPHERS}:!DH" + + +class PatchedHTTPAdapter(requests.adapters.HTTPAdapter): + """ An adapter to block DH ciphers which may not work for the tax agencies called""" + + def init_poolmanager(self, *args, **kwargs): + # OVERRIDE + kwargs['ssl_context'] = create_urllib3_context(ciphers=EUSKADI_CIPHERS) + return super().init_poolmanager(*args, **kwargs) + + def cert_verify(self, conn, url, verify, cert): + # OVERRIDE + # The last parameter is only used by the super method to check if the file exists. + # In our case, cert is an odoo record 'l10n_es_edi.certificate' so not a path to a file. + # By putting 'None' as last parameter, we ensure the check about TLS configuration is + # still made without checking temporary files exist. + super().cert_verify(conn, url, verify, None) + conn.cert_file = cert + conn.key_file = cert + + def get_connection(self, url, proxies=None): + # OVERRIDE + # Patch the OpenSSLContext to decode the certificate in-memory. + conn = super().get_connection(url, proxies=proxies) + + context = conn.conn_kw['ssl_context'] + + def patched_load_cert_chain(l10n_es_odoo_certificate, keyfile=None, password=None): + cert_file, key_file, _certificate = l10n_es_odoo_certificate._decode_certificate() + cert_obj = load_certificate(FILETYPE_PEM, cert_file) + pkey_obj = load_privatekey(FILETYPE_PEM, key_file) + + context._ctx.use_certificate(cert_obj) + context._ctx.use_privatekey(pkey_obj) + + context.load_cert_chain = patched_load_cert_chain + + return conn + + +class AccountEdiFormat(models.Model): + _inherit = 'account.edi.format' + + # ------------------------------------------------------------------------- + # ES EDI + # ------------------------------------------------------------------------- + + def _l10n_es_edi_get_invoices_tax_details_info(self, invoice, filter_invl_to_apply=None): + + def grouping_key_generator(tax_values): + tax = tax_values['tax_id'] + return { + 'applied_tax_amount': tax.amount, + 'l10n_es_type': tax.l10n_es_type, + 'l10n_es_exempt_reason': tax.l10n_es_exempt_reason if tax.l10n_es_type == 'exento' else False, + 'l10n_es_bien_inversion': tax.l10n_es_bien_inversion, + } + + def filter_to_apply(tax_values): + # For intra-community, we do not take into account the negative repartition line + return tax_values['tax_repartition_line_id'].factor_percent > 0.0 + + def full_filter_invl_to_apply(invoice_line): + if 'ignore' in invoice_line.tax_ids.flatten_taxes_hierarchy().mapped('l10n_es_type'): + return False + return filter_invl_to_apply(invoice_line) if filter_invl_to_apply else True + + tax_details = invoice._prepare_edi_tax_details( + grouping_key_generator=grouping_key_generator, + filter_invl_to_apply=full_filter_invl_to_apply, + filter_to_apply=filter_to_apply, + ) + sign = -1 if invoice.is_sale_document() else 1 + + tax_details_info = defaultdict(dict) + + # Detect for which is the main tax for 'recargo'. Since only a single combination tax + recargo is allowed + # on the same invoice, this can be deduced globally. + + recargo_tax_details = {} # Mapping between main tax and recargo tax details + invoice_lines = invoice.invoice_line_ids.filtered(lambda x: not x.display_type) + if filter_invl_to_apply: + invoice_lines = invoice_lines.filtered(filter_invl_to_apply) + for line in invoice_lines: + taxes = line.tax_ids.flatten_taxes_hierarchy() + recargo_tax = [t for t in taxes if t.l10n_es_type == 'recargo'] + if recargo_tax and taxes: + recargo_main_tax = taxes.filtered(lambda x: x.l10n_es_type in ('sujeto', 'sujeto_isp'))[:1] + if not recargo_tax_details.get(recargo_main_tax): + recargo_tax_details[recargo_main_tax] = [ + x for x in tax_details['tax_details'].values() + if x['group_tax_details'][0]['tax_id'] == recargo_tax[0] + ][0] + + tax_amount_deductible = 0.0 + tax_amount_retention = 0.0 + base_amount_not_subject = 0.0 + base_amount_not_subject_loc = 0.0 + tax_subject_info_list = [] + tax_subject_isp_info_list = [] + for tax_values in tax_details['tax_details'].values(): + + if invoice.is_sale_document(): + # Customer invoices + + if tax_values['l10n_es_type'] in ('sujeto', 'sujeto_isp'): + tax_amount_deductible += tax_values['tax_amount'] + + base_amount = sign * tax_values['base_amount'] + tax_info = { + 'TipoImpositivo': tax_values['applied_tax_amount'], + 'BaseImponible': round(base_amount, 2), + 'CuotaRepercutida': round(math.copysign(tax_values['tax_amount'], base_amount), 2), + } + + recargo = recargo_tax_details.get(tax_values['group_tax_details'][0]['tax_id']) + if recargo: + tax_info['CuotaRecargoEquivalencia'] = round(sign * recargo['tax_amount'], 2) + tax_info['TipoRecargoEquivalencia'] = recargo['applied_tax_amount'] + + if tax_values['l10n_es_type'] == 'sujeto': + tax_subject_info_list.append(tax_info) + else: + tax_subject_isp_info_list.append(tax_info) + + elif tax_values['l10n_es_type'] == 'exento': + tax_details_info['Sujeta'].setdefault('Exenta', {'DetalleExenta': []}) + tax_details_info['Sujeta']['Exenta']['DetalleExenta'].append({ + 'BaseImponible': round(sign * tax_values['base_amount'], 2), + 'CausaExencion': tax_values['l10n_es_exempt_reason'], + }) + elif tax_values['l10n_es_type'] == 'retencion': + tax_amount_retention += tax_values['tax_amount'] + elif tax_values['l10n_es_type'] == 'no_sujeto': + base_amount_not_subject += tax_values['base_amount'] + elif tax_values['l10n_es_type'] == 'no_sujeto_loc': + base_amount_not_subject_loc += tax_values['base_amount'] + elif tax_values['l10n_es_type'] == 'ignore': + continue + + if tax_subject_isp_info_list and not tax_subject_info_list: + tax_details_info['Sujeta']['NoExenta'] = {'TipoNoExenta': 'S2'} + elif not tax_subject_isp_info_list and tax_subject_info_list: + tax_details_info['Sujeta']['NoExenta'] = {'TipoNoExenta': 'S1'} + elif tax_subject_isp_info_list and tax_subject_info_list: + tax_details_info['Sujeta']['NoExenta'] = {'TipoNoExenta': 'S3'} + + if tax_subject_info_list: + tax_details_info['Sujeta']['NoExenta'].setdefault('DesgloseIVA', {}) + tax_details_info['Sujeta']['NoExenta']['DesgloseIVA'].setdefault('DetalleIVA', []) + tax_details_info['Sujeta']['NoExenta']['DesgloseIVA']['DetalleIVA'] += tax_subject_info_list + if tax_subject_isp_info_list: + tax_details_info['Sujeta']['NoExenta'].setdefault('DesgloseIVA', {}) + tax_details_info['Sujeta']['NoExenta']['DesgloseIVA'].setdefault('DetalleIVA', []) + tax_details_info['Sujeta']['NoExenta']['DesgloseIVA']['DetalleIVA'] += tax_subject_isp_info_list + + else: + # Vendor bills + if tax_values['l10n_es_type'] in ('sujeto', 'sujeto_isp', 'no_sujeto', 'no_sujeto_loc'): + tax_amount_deductible += tax_values['tax_amount'] + elif tax_values['l10n_es_type'] == 'retencion': + tax_amount_retention += tax_values['tax_amount'] + elif tax_values['l10n_es_type'] == 'no_sujeto': + base_amount_not_subject += tax_values['base_amount'] + elif tax_values['l10n_es_type'] == 'no_sujeto_loc': + base_amount_not_subject_loc += tax_values['base_amount'] + elif tax_values['l10n_es_type'] == 'ignore': + continue + + if tax_values['l10n_es_type'] not in ['retencion', 'recargo']: # = in sujeto/sujeto_isp/no_deducible + base_amount = sign * tax_values['base_amount'] + tax_details_info.setdefault('DetalleIVA', []) + tax_info = { + 'BaseImponible': round(base_amount, 2), + } + if tax_values['applied_tax_amount'] > 0.0: + tax_info.update({ + 'TipoImpositivo': tax_values['applied_tax_amount'], + 'CuotaSoportada': round(math.copysign(tax_values['tax_amount'], base_amount), 2), + }) + if tax_values['l10n_es_bien_inversion']: + tax_info['BienInversion'] = 'S' + recargo = recargo_tax_details.get(tax_values['group_tax_details'][0]['tax_id']) + if recargo: + tax_info['CuotaRecargoEquivalencia'] = round(sign * recargo['tax_amount'], 2) + tax_info['TipoRecargoEquivalencia'] = recargo['applied_tax_amount'] + tax_details_info['DetalleIVA'].append(tax_info) + + if not invoice.company_id.currency_id.is_zero(base_amount_not_subject) and invoice.is_sale_document(): + tax_details_info['NoSujeta']['ImportePorArticulos7_14_Otros'] = round(sign * base_amount_not_subject, 2) + if not invoice.company_id.currency_id.is_zero(base_amount_not_subject_loc) and invoice.is_sale_document(): + tax_details_info['NoSujeta']['ImporteTAIReglasLocalizacion'] = round(sign * base_amount_not_subject_loc, 2) + + return { + 'tax_details_info': tax_details_info, + 'tax_details': tax_details, + 'tax_amount_deductible': tax_amount_deductible, + 'tax_amount_retention': tax_amount_retention, + 'base_amount_not_subject': base_amount_not_subject, + } + + def _l10n_es_edi_get_partner_info(self, partner): + eu_country_codes = set(self.env.ref('base.europe').country_ids.mapped('code')) + + partner_info = {} + IDOtro_ID = partner.vat or 'NO_DISPONIBLE' + + if (not partner.country_id or partner.country_id.code == 'ES') and partner.vat: + # ES partner with VAT. + partner_info['NIF'] = partner.vat[2:] if partner.vat.startswith('ES') else partner.vat + elif partner.country_id.code in eu_country_codes: + # European partner. + partner_info['IDOtro'] = {'IDType': '02', 'ID': IDOtro_ID} + else: + partner_info['IDOtro'] = {'ID': IDOtro_ID} + if partner.vat: + partner_info['IDOtro']['IDType'] = '04' + else: + partner_info['IDOtro']['IDType'] = '06' + if partner.country_id: + partner_info['IDOtro']['CodigoPais'] = partner.country_id.code + return partner_info + + def _l10n_es_edi_get_invoices_info(self, invoices): + eu_country_codes = set(self.env.ref('base.europe').country_ids.mapped('code')) + + simplified_partner = self.env.ref("l10n_es_edi_sii.partner_simplified") + + info_list = [] + for invoice in invoices: + com_partner = invoice.commercial_partner_id + is_simplified = invoice.partner_id == simplified_partner + + info = { + 'PeriodoLiquidacion': { + 'Ejercicio': str(invoice.date.year), + 'Periodo': str(invoice.date.month).zfill(2), + }, + 'IDFactura': { + 'FechaExpedicionFacturaEmisor': invoice.invoice_date.strftime('%d-%m-%Y'), + }, + } + + if invoice.is_sale_document(): + invoice_node = info['FacturaExpedida'] = {} + else: + invoice_node = info['FacturaRecibida'] = {} + + # === Partner === + + partner_info = self._l10n_es_edi_get_partner_info(com_partner) + + # === Invoice === + + invoice_node['DescripcionOperacion'] = invoice.invoice_origin or 'manual' + if invoice.is_sale_document(): + info['IDFactura']['IDEmisorFactura'] = {'NIF': invoice.company_id.vat[2:]} + info['IDFactura']['NumSerieFacturaEmisor'] = invoice.name[:60] + if not is_simplified: + invoice_node['Contraparte'] = { + **partner_info, + 'NombreRazon': com_partner.name[:120], + } + + if not com_partner.country_id or com_partner.country_id.code in eu_country_codes: + invoice_node['ClaveRegimenEspecialOTrascendencia'] = '01' + else: + invoice_node['ClaveRegimenEspecialOTrascendencia'] = '02' + else: + info['IDFactura']['IDEmisorFactura'] = partner_info + info['IDFactura']['NumSerieFacturaEmisor'] = invoice.ref[:60] + if not is_simplified: + invoice_node['Contraparte'] = { + **partner_info, + 'NombreRazon': com_partner.name[:120], + } + + if invoice.l10n_es_registration_date: + invoice_node['FechaRegContable'] = invoice.l10n_es_registration_date.strftime('%d-%m-%Y') + else: + invoice_node['FechaRegContable'] = fields.Date.context_today(self).strftime('%d-%m-%Y') + + if not com_partner.country_id or com_partner.country_id.code == 'ES': + invoice_node['ClaveRegimenEspecialOTrascendencia'] = '01' + else: + invoice_node['ClaveRegimenEspecialOTrascendencia'] = '09' + + if invoice.move_type == 'out_invoice': + invoice_node['TipoFactura'] = 'F2' if is_simplified else 'F1' + elif invoice.move_type == 'out_refund': + invoice_node['TipoFactura'] = 'R5' if is_simplified else 'R1' + invoice_node['TipoRectificativa'] = 'I' + elif invoice.move_type == 'in_invoice': + invoice_node['TipoFactura'] = 'F1' + elif invoice.move_type == 'in_refund': + invoice_node['TipoFactura'] = 'R4' + invoice_node['TipoRectificativa'] = 'I' + + # === Taxes === + + sign = -1 if invoice.is_sale_document() else 1 + + if invoice.is_sale_document(): + # Customer invoices + + if com_partner.country_id.code in ('ES', False) and not (com_partner.vat or '').startswith("ESN"): + tax_details_info_vals = self._l10n_es_edi_get_invoices_tax_details_info(invoice) + invoice_node['TipoDesglose'] = {'DesgloseFactura': tax_details_info_vals['tax_details_info']} + + invoice_node['ImporteTotal'] = round(sign * ( + tax_details_info_vals['tax_details']['base_amount'] + + tax_details_info_vals['tax_details']['tax_amount'] + - tax_details_info_vals['tax_amount_retention'] + ), 2) + else: + tax_details_info_service_vals = self._l10n_es_edi_get_invoices_tax_details_info( + invoice, + filter_invl_to_apply=lambda x: any(t.tax_scope == 'service' for t in x.tax_ids) + ) + tax_details_info_consu_vals = self._l10n_es_edi_get_invoices_tax_details_info( + invoice, + filter_invl_to_apply=lambda x: any(t.tax_scope == 'consu' for t in x.tax_ids) + ) + + if tax_details_info_service_vals['tax_details_info']: + invoice_node.setdefault('TipoDesglose', {}) + invoice_node['TipoDesglose'].setdefault('DesgloseTipoOperacion', {}) + invoice_node['TipoDesglose']['DesgloseTipoOperacion']['PrestacionServicios'] = tax_details_info_service_vals['tax_details_info'] + if tax_details_info_consu_vals['tax_details_info']: + invoice_node.setdefault('TipoDesglose', {}) + invoice_node['TipoDesglose'].setdefault('DesgloseTipoOperacion', {}) + invoice_node['TipoDesglose']['DesgloseTipoOperacion']['Entrega'] = tax_details_info_consu_vals['tax_details_info'] + + invoice_node['ImporteTotal'] = round(sign * ( + tax_details_info_service_vals['tax_details']['base_amount'] + + tax_details_info_service_vals['tax_details']['tax_amount'] + - tax_details_info_service_vals['tax_amount_retention'] + + tax_details_info_consu_vals['tax_details']['base_amount'] + + tax_details_info_consu_vals['tax_details']['tax_amount'] + - tax_details_info_consu_vals['tax_amount_retention'] + ), 2) + + else: + # Vendor bills + + tax_details_info_isp_vals = self._l10n_es_edi_get_invoices_tax_details_info( + invoice, + filter_invl_to_apply=lambda x: any(t for t in x.tax_ids if t.l10n_es_type == 'sujeto_isp'), + ) + tax_details_info_other_vals = self._l10n_es_edi_get_invoices_tax_details_info( + invoice, + filter_invl_to_apply=lambda x: not any(t for t in x.tax_ids if t.l10n_es_type == 'sujeto_isp'), + ) + + invoice_node['DesgloseFactura'] = {} + if tax_details_info_isp_vals['tax_details_info']: + invoice_node['DesgloseFactura']['InversionSujetoPasivo'] = tax_details_info_isp_vals['tax_details_info'] + if tax_details_info_other_vals['tax_details_info']: + invoice_node['DesgloseFactura']['DesgloseIVA'] = tax_details_info_other_vals['tax_details_info'] + + invoice_node['ImporteTotal'] = round(sign * ( + tax_details_info_isp_vals['tax_details']['base_amount'] + + tax_details_info_isp_vals['tax_details']['tax_amount'] + - tax_details_info_isp_vals['tax_amount_retention'] + + tax_details_info_other_vals['tax_details']['base_amount'] + + tax_details_info_other_vals['tax_details']['tax_amount'] + - tax_details_info_other_vals['tax_amount_retention'] + ), 2) + + invoice_node['CuotaDeducible'] = round(sign * ( + tax_details_info_isp_vals['tax_amount_deductible'] + + tax_details_info_other_vals['tax_amount_deductible'] + ), 2) + + info_list.append(info) + return info_list + + def _l10n_es_edi_web_service_aeat_vals(self, invoices): + if invoices[0].is_sale_document(): + return {'url': 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/ssii_1_1/fact/ws/SuministroFactEmitidas.wsdl'} + else: + return {'url': 'https://www2.agenciatributaria.gob.es/static_files/common/internet/dep/aplicaciones/es/aeat/ssii_1_1/fact/ws/SuministroFactRecibidas.wsdl'} + + def _l10n_es_edi_web_service_bizkaia_vals(self, invoices): + if invoices[0].is_sale_document(): + return { + 'url': 'https://www.bizkaia.eus/ogasuna/sii/documentos/SuministroFactEmitidas.wsdl', + 'test_url': 'https://pruapps.bizkaia.eus/SSII-FACT/ws/fe/SiiFactFEV1SOAP', + } + else: + return { + 'url': 'https://www.bizkaia.eus/ogasuna/sii/documentos/SuministroFactRecibidas.wsdl', + 'test_url': 'https://pruapps.bizkaia.eus/SSII-FACT/ws/fr/SiiFactFRV1SOAP', + } + + def _l10n_es_edi_web_service_gipuzkoa_vals(self, invoices): + if invoices[0].is_sale_document(): + return { + 'url': 'https://egoitza.gipuzkoa.eus/ogasuna/sii/ficheros/v1.1/SuministroFactEmitidas.wsdl', + 'test_url': 'https://sii-prep.egoitza.gipuzkoa.eus/JBS/HACI/SSII-FACT/ws/fe/SiiFactFEV1SOAP', + } + else: + return { + 'url': 'https://egoitza.gipuzkoa.eus/ogasuna/sii/ficheros/v1.1/SuministroFactRecibidas.wsdl', + 'test_url': 'https://sii-prep.egoitza.gipuzkoa.eus/JBS/HACI/SSII-FACT/ws/fr/SiiFactFRV1SOAP', + } + + def _l10n_es_edi_call_web_service_sign(self, invoices, info_list): + company = invoices.company_id + + # All are sharing the same value, see '_get_batch_key'. + csv_number = invoices.mapped('l10n_es_edi_csv')[0] + + # Set registration date + invoices.filtered(lambda inv: not inv.l10n_es_registration_date).write({ + 'l10n_es_registration_date': fields.Date.context_today(self), + }) + + # === Call the web service === + + # Get connection data. + l10n_es_edi_tax_agency = company.mapped('l10n_es_edi_tax_agency')[0] + connection_vals = getattr(self, f'_l10n_es_edi_web_service_{l10n_es_edi_tax_agency}_vals')(invoices) + + header = { + 'IDVersionSii': '1.1', + 'Titular': { + 'NombreRazon': company.name[:120], + 'NIF': company.vat[2:], + }, + 'TipoComunicacion': 'A1' if csv_number else 'A0', + } + + session = requests.Session() + session.cert = company.l10n_es_edi_certificate_id + session.mount('https://', PatchedHTTPAdapter()) + + transport = Transport(operation_timeout=60, timeout=60, session=session) + client = zeep.Client(connection_vals['url'], transport=transport) + + if invoices[0].is_sale_document(): + service_name = 'SuministroFactEmitidas' + else: + service_name = 'SuministroFactRecibidas' + if company.l10n_es_edi_test_env and not connection_vals.get('test_url'): + service_name += 'Pruebas' + + # Establish the connection. + serv = client.bind('siiService', service_name) + if company.l10n_es_edi_test_env and connection_vals.get('test_url'): + serv._binding_options['address'] = connection_vals['test_url'] + + error_msg = None + try: + if invoices[0].is_sale_document(): + res = serv.SuministroLRFacturasEmitidas(header, info_list) + else: + res = serv.SuministroLRFacturasRecibidas(header, info_list) + except requests.exceptions.SSLError as error: + error_msg = _("The SSL certificate could not be validated.") + except zeep.exceptions.Error as error: + error_msg = _("Networking error:\n%s", error) + except Exception as error: + error_msg = str(error) + + if error_msg: + return {inv: { + 'error': error_msg, + 'blocking_level': 'warning', + } for inv in invoices} + + # Process response. + + if not res or not res.RespuestaLinea: + return {inv: { + 'error': _("The web service is not responding"), + 'blocking_level': 'warning', + } for inv in invoices} + + resp_state = res["EstadoEnvio"] + l10n_es_edi_csv = res['CSV'] + + if resp_state == 'Correcto': + invoices.write({'l10n_es_edi_csv': l10n_es_edi_csv}) + return {inv: {'success': True} for inv in invoices} + + results = {} + for respl in res.RespuestaLinea: + invoice_number = respl.IDFactura.NumSerieFacturaEmisor + + # Retrieve the corresponding invoice. + # Note: ref can be the same for different partners but there is no enough information on the response + # to match the partner. + + # Note: Invoices are batched per move_type. + if invoices[0].is_sale_document(): + inv = invoices.filtered(lambda x: x.name[:60] == invoice_number) + else: + # 'ref' can be the same for different partners. + candidates = invoices.filtered(lambda x: x.ref[:60] == invoice_number) + if len(candidates) >= 1: + respl_partner_info = respl.IDFactura.IDEmisorFactura + inv = None + for candidate in candidates: + partner_info = self._l10n_es_edi_get_partner_info(candidate.commercial_partner_id) + if partner_info.get('NIF') and partner_info['NIF'] == respl_partner_info.NIF: + inv = candidate + break + if partner_info.get('IDOtro') and all(getattr(respl_partner_info.IDOtro, k) == v + for k, v in partner_info['IDOtro'].items()): + inv = candidate + break + + if not inv: + # This case shouldn't happen and means there is something wrong in this code. However, we can't + # raise anything since the document has already been approved by the government. The result + # will only be a badly logged message into the chatter so, not a big deal. + inv = candidates[0] + else: + inv = candidates + + resp_line_state = respl.EstadoRegistro + if resp_line_state in ('Correcto', 'AceptadoConErrores'): + inv.l10n_es_edi_csv = l10n_es_edi_csv + results[inv] = {'success': True} + if resp_line_state == 'AceptadoConErrores': + inv.message_post(body=_("This was accepted with errors: ") + html_escape(respl.DescripcionErrorRegistro)) + elif respl.RegistroDuplicado: + results[inv] = {'success': True} + inv.message_post(body=_("We saw that this invoice was sent correctly before, but we did not treat " + "the response. Make sure it is not because of a wrong configuration.")) + else: + results[inv] = { + 'error': _("[%s] %s", respl.CodigoErrorRegistro, respl.DescripcionErrorRegistro), + 'blocking_level': 'error', + } + + return results + + # ------------------------------------------------------------------------- + # EDI OVERRIDDEN METHODS + # ------------------------------------------------------------------------- + + def _is_required_for_invoice(self, invoice): + # OVERRIDE + if self.code != 'es_sii': + return super()._is_required_for_invoice(invoice) + + return invoice.l10n_es_edi_is_required + + def _needs_web_services(self): + # OVERRIDE + return self.code == 'es_sii' or super()._needs_web_services() + + def _support_batching(self, move=None, state=None, company=None): + # OVERRIDE + if self.code != 'es_sii': + return super()._support_batching(move=move, state=state, company=company) + + return state == 'to_send' and move.is_invoice() + + def _get_batch_key(self, move, state): + # OVERRIDE + if self.code != 'es_sii': + return super()._get_batch_key(move, state) + + return move.move_type, move.l10n_es_edi_csv + + def _check_move_configuration(self, move): + # OVERRIDE + res = super()._check_move_configuration(move) + if self.code != 'es_sii': + return res + + if not move.company_id.vat: + res.append(_("VAT number is missing on company %s", move.company_id.display_name)) + if not move.partner_id.vat: + res.append(_("VAT number needs to be configured on the partner %s", move.partner_id.display_name)) + for line in move.invoice_line_ids.filtered(lambda line: not line.display_type): + taxes = line.tax_ids.flatten_taxes_hierarchy() + recargo_count = taxes.mapped('l10n_es_type').count('recargo') + retention_count = taxes.mapped('l10n_es_type').count('retencion') + sujeto_count = taxes.mapped('l10n_es_type').count('sujeto') + no_sujeto_count = taxes.mapped('l10n_es_type').count('no_sujeto') + no_sujeto_loc_count = taxes.mapped('l10n_es_type').count('no_sujeto_loc') + if retention_count > 1: + res.append(_("Line %s should only have one retention tax.", line.display_name)) + if recargo_count > 1: + res.append(_("Line %s should only have one recargo tax.", line.display_name)) + if sujeto_count > 1: + res.append(_("Line %s should only have one sujeto tax.", line.display_name)) + if no_sujeto_count > 1: + res.append(_("Line %s should only have one no sujeto tax.", line.display_name)) + if no_sujeto_loc_count > 1: + res.append(_("Line %s should only have one no sujeto (localizations) tax.", line.display_name)) + if sujeto_count + no_sujeto_loc_count + no_sujeto_count > 1: + res.append(_("Line %s should only have one main tax.", line.display_name)) + if move.move_type in ('in_invoice', 'in_refund'): + if not move.ref: + res.append(_("You should put a vendor reference on this vendor bill. ")) + return res + + def _is_compatible_with_journal(self, journal): + # OVERRIDE + if self.code != 'es_sii': + return super()._is_compatible_with_journal(journal) + + return journal.country_code == 'ES' + + def _post_invoice_edi(self, invoices): + # OVERRIDE + if self.code != 'es_sii': + return super()._post_invoice_edi(invoices) + + # Ensure a certificate is available. + certificate = invoices.company_id.l10n_es_edi_certificate_id + if not certificate: + return {inv: { + 'error': _("Please configure the certificate for SII."), + 'blocking_level': 'error', + } for inv in invoices} + + # Ensure a tax agency is available. + l10n_es_edi_tax_agency = invoices.company_id.mapped('l10n_es_edi_tax_agency')[0] + if not l10n_es_edi_tax_agency: + return {inv: { + 'error': _("Please specify a tax agency on your company for SII."), + 'blocking_level': 'error', + } for inv in invoices} + + # Generate the JSON. + info_list = self._l10n_es_edi_get_invoices_info(invoices) + + # Call the web service. + res = self._l10n_es_edi_call_web_service_sign(invoices, info_list) + + for inv in invoices: + if res.get(inv, {}).get('success'): + attachment = self.env['ir.attachment'].create({ + 'type': 'binary', + 'name': 'jsondump.json', + 'raw': json.dumps(info_list), + 'mimetype': 'application/json', + 'res_model': inv._name, + 'res_id': inv.id, + }) + res[inv] = {'attachment': attachment} + return res diff --git a/addons/l10n_es_edi_sii/models/account_move.py b/addons/l10n_es_edi_sii/models/account_move.py new file mode 100644 index 00000000000..854c8a17f07 --- /dev/null +++ b/addons/l10n_es_edi_sii/models/account_move.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class AccountMove(models.Model): + _inherit = 'account.move' + + l10n_es_edi_is_required = fields.Boolean( + string="Is the Spanish EDI needed", + compute='_compute_l10n_es_edi_is_required' + ) + l10n_es_edi_csv = fields.Char(string="CSV return code", copy=False) + l10n_es_registration_date = fields.Date( + string="Registration Date", + help="Technical field to keep the date the invoice was sent the first time as the date the invoice was " + "registered into the system.", + ) + + # ------------------------------------------------------------------------- + # COMPUTE METHODS + # ------------------------------------------------------------------------- + + @api.depends('move_type', 'company_id') + def _compute_l10n_es_edi_is_required(self): + for move in self: + move.l10n_es_edi_is_required = move.is_invoice() \ + and move.country_code == 'ES' \ + and move.company_id.l10n_es_edi_tax_agency + + @api.depends('l10n_es_edi_is_required') + def _compute_edi_show_cancel_button(self): + super()._compute_edi_show_cancel_button() + for move in self.filtered('l10n_es_edi_is_required'): + move.edi_show_cancel_button = False diff --git a/addons/l10n_es_edi_sii/models/account_tax.py b/addons/l10n_es_edi_sii/models/account_tax.py new file mode 100644 index 00000000000..602f656ede9 --- /dev/null +++ b/addons/l10n_es_edi_sii/models/account_tax.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields, models + + +class SIIAccountTaxMixin(models.AbstractModel): + _name = 'l10n_es.sii.account.tax.mixin' + _description = 'SII Fields' + + l10n_es_exempt_reason = fields.Selection( + selection=[ + ('E1', 'Art. 20'), + ('E2', 'Art. 21'), + ('E3', 'Art. 22'), + ('E4', 'Art. 23 y 24'), + ('E5', 'Art. 25'), + ('E6', 'Otros'), + ], + string="Exempt Reason (Spain)", + ) + l10n_es_type = fields.Selection( + selection=[ + ('exento', 'Exento'), + ('sujeto', 'Sujeto'), + ('sujeto_agricultura', 'Sujeto Agricultura'), + ('sujeto_isp', 'Sujeto ISP'), + ('no_sujeto', 'No Sujeto'), + ('no_sujeto_loc', 'No Sujeto por reglas de Localization'), + ('no_deducible', 'No Deducible'), + ('retencion', 'Retencion'), + ('recargo', 'Recargo de Equivalencia'), + ('ignore', 'Ignore even the base amount'), + ], + string="Tax Type (Spain)", default='sujeto' + ) + l10n_es_bien_inversion = fields.Boolean('Bien de Inversion', default=False) + + +class AccountTax(models.Model): + _inherit = ['account.tax', 'l10n_es.sii.account.tax.mixin'] + _name = 'account.tax' + + +class AccountTaxTemplate(models.Model): + _inherit = ['account.tax.template', 'l10n_es.sii.account.tax.mixin'] + _name = 'account.tax.template' + + def _get_tax_vals(self, company, tax_template_to_tax): + # OVERRIDE + # Copy values from 'account.tax.template' to vals will be used to create a new 'account.tax'. + vals = super()._get_tax_vals(company, tax_template_to_tax) + vals['l10n_es_exempt_reason'] = self.l10n_es_exempt_reason + vals['l10n_es_type'] = self.l10n_es_type + vals['l10n_es_bien_inversion'] = self.l10n_es_bien_inversion + return vals diff --git a/addons/l10n_es_edi_sii/models/l10n_es_edi_certificate.py b/addons/l10n_es_edi_sii/models/l10n_es_edi_certificate.py new file mode 100644 index 00000000000..ef02f07f33f --- /dev/null +++ b/addons/l10n_es_edi_sii/models/l10n_es_edi_certificate.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from base64 import b64decode +from pytz import timezone +from datetime import datetime +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, pkcs12 + + +from odoo import _, api, fields, models, tools +from odoo.exceptions import ValidationError + + +class Certificate(models.Model): + _name = 'l10n_es_edi.certificate' + _description = 'Personal Digital Certificate' + _order = 'date_start desc, id desc' + _rec_name = 'date_start' + + content = fields.Binary(string="File", required=True, help="PFX Certificate") + password = fields.Char(help="Passphrase for the PFX certificate", groups="base.group_system") + date_start = fields.Datetime(readonly=True, help="The date on which the certificate starts to be valid") + date_end = fields.Datetime(readonly=True, help="The date on which the certificate expires") + company_id = fields.Many2one(comodel_name='res.company', required=True, default=lambda self: self.env.company) + + # ------------------------------------------------------------------------- + # HELPERS + # ------------------------------------------------------------------------- + + @api.model + def _get_es_current_datetime(self): + """Get the current datetime with the Peruvian timezone. """ + return datetime.now(timezone('Europe/Madrid')) + + @tools.ormcache('self.content', 'self.password') + def _decode_certificate(self): + """Return the content (DER encoded) and the certificate decrypted based in the point 3.1 from the RS 097-2012 + http://www.vauxoo.com/r/manualdeautorizacion#page=21 + """ + self.ensure_one() + + if not self.password: + return None, None, None + + private_key, certificate, _additional_certificates = pkcs12.load_key_and_certificates( + b64decode(self.content), + self.password.encode(), + backend=default_backend(), + ) + + pem_certificate = certificate.public_bytes(Encoding.PEM) + pem_private_key = private_key.private_bytes( + Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption(), + ) + return pem_certificate, pem_private_key, certificate + + # ------------------------------------------------------------------------- + # LOW-LEVEL METHODS + # ------------------------------------------------------------------------- + + @api.model + def create(self, vals): + record = super().create(vals) + + spain_tz = timezone('Europe/Madrid') + spain_dt = self._get_es_current_datetime() + try: + _pem_certificate, _pem_private_key, certificate = record._decode_certificate() + cert_date_start = spain_tz.localize(certificate.not_valid_before) + cert_date_end = spain_tz.localize(certificate.not_valid_after) + except Exception: + raise ValidationError(_( + "There has been a problem with the certificate, some usual problems can be:\n" + "- The password given or the certificate are not valid.\n" + "- The certificate content is invalid." + )) + # Assign extracted values from the certificate + record.write({ + 'date_start': fields.Datetime.to_string(cert_date_start), + 'date_end': fields.Datetime.to_string(cert_date_end), + }) + if spain_dt > cert_date_end: + raise ValidationError(_("The certificate is expired since %s", record.date_end)) + return record diff --git a/addons/l10n_es_edi_sii/models/res_company.py b/addons/l10n_es_edi_sii/models/res_company.py new file mode 100644 index 00000000000..8ee3cc98142 --- /dev/null +++ b/addons/l10n_es_edi_sii/models/res_company.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import api, fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + l10n_es_edi_certificate_id = fields.Many2one( + string="Certificate (ES)", + store=True, + readonly=False, + comodel_name='l10n_es_edi.certificate', + compute="_compute_l10n_es_edi_certificate", + ) + l10n_es_edi_certificate_ids = fields.One2many( + comodel_name='l10n_es_edi.certificate', + inverse_name='company_id', + ) + l10n_es_edi_tax_agency = fields.Selection( + string="Tax Agency for SII", + selection=[ + ('aeat', "Agencia Tributaria española"), + ('gipuzkoa', "Hacienda Foral de Gipuzkoa"), + ('bizkaia', "Hacienda Foral de Bizkaia"), + ], + default=False, + ) + l10n_es_edi_test_env = fields.Boolean( + string="Test Mode", + help="Use the test environment", + ) + + @api.depends('country_id', 'l10n_es_edi_certificate_ids') + def _compute_l10n_es_edi_certificate(self): + for company in self: + if company.country_code == 'ES': + company.l10n_es_edi_certificate_id = self.env['l10n_es_edi.certificate'].search( + [('company_id', '=', company.id)], + order='date_end desc', + limit=1, + ) + else: + company.l10n_es_edi_certificate_id = False diff --git a/addons/l10n_es_edi_sii/models/res_config_settings.py b/addons/l10n_es_edi_sii/models/res_config_settings.py new file mode 100644 index 00000000000..35a2cbe609d --- /dev/null +++ b/addons/l10n_es_edi_sii/models/res_config_settings.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + l10n_es_edi_certificate_ids = fields.One2many(related='company_id.l10n_es_edi_certificate_ids', readonly=False) + l10n_es_edi_tax_agency = fields.Selection(related='company_id.l10n_es_edi_tax_agency', readonly=False) + l10n_es_edi_test_env = fields.Boolean(related='company_id.l10n_es_edi_test_env', readonly=False) diff --git a/addons/l10n_es_edi_sii/security/ir.model.access.csv b/addons/l10n_es_edi_sii/security/ir.model.access.csv new file mode 100644 index 00000000000..eb7730275cb --- /dev/null +++ b/addons/l10n_es_edi_sii/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink +access_l10n_es_edi_certificate,access_l10n_es_edi_certificate,model_l10n_es_edi_certificate,base.group_system,1,1,1,1 diff --git a/addons/l10n_es_edi_sii/tests/__init__.py b/addons/l10n_es_edi_sii/tests/__init__.py new file mode 100644 index 00000000000..78cb5d78f6d --- /dev/null +++ b/addons/l10n_es_edi_sii/tests/__init__.py @@ -0,0 +1,4 @@ +# coding: utf-8 + +from . import test_edi_xml +from . import test_edi_web_services diff --git a/addons/l10n_es_edi_sii/tests/common.py b/addons/l10n_es_edi_sii/tests/common.py new file mode 100644 index 00000000000..fb5ad72cdf6 --- /dev/null +++ b/addons/l10n_es_edi_sii/tests/common.py @@ -0,0 +1,77 @@ +# coding: utf-8 +import base64 +from pytz import timezone +from datetime import datetime + +from odoo.tests import tagged +from odoo.tools import misc +from odoo.addons.account_edi.tests.common import AccountEdiTestCommon + + +@tagged('post_install_l10n', 'post_install', '-at_install') +class TestEsEdiCommon(AccountEdiTestCommon): + + @classmethod + def setUpClass(cls, chart_template_ref='l10n_es.account_chart_template_full', edi_format_ref='l10n_es_edi_sii.edi_es_sii'): + super().setUpClass(chart_template_ref=chart_template_ref, edi_format_ref=edi_format_ref) + + cls.frozen_today = datetime(year=2019, month=1, day=1, hour=0, minute=0, second=0, tzinfo=timezone('utc')) + + # Allow to see the full result of AssertionError. + cls.maxDiff = None + + # ==== Config ==== + + cls.certificate = cls.env['l10n_es_edi.certificate'].create({ + 'content': base64.encodebytes( + misc.file_open("l10n_es_edi_sii/demo/certificates/sello_entidad_act.p12", 'rb').read()), + 'password': 'IZDesa2021', + }) + + cls.company_data['company'].write({ + 'country_id': cls.env.ref('base.es').id, + 'state_id': cls.env.ref('base.state_es_z').id, + 'l10n_es_edi_certificate_id': cls.certificate.id, + 'vat': 'ES59962470K', + 'l10n_es_edi_test_env': True, + 'l10n_es_edi_tax_agency': 'bizkaia', + }) + + # ==== Business ==== + + cls.partner_a.write({ + 'vat': 'BE0477472701', + 'country_id': cls.env.ref('base.be').id, + }) + + cls.partner_b.write({ + 'vat': 'ESF35999705', + }) + + cls.product_t = cls.env["product.product"].create( + {"name": "Test product"}) + cls.partner_t = cls.env["res.partner"].create({"name": "Test partner", "vat": "ESF35999705"}) + + @classmethod + def _get_tax_by_xml_id(cls, trailing_xml_id): + """ Helper to retrieve a tax easily. + + :param trailing_xml_id: The trailing tax's xml id. + :return: An account.tax record + """ + return cls.env.ref(f'l10n_es.{cls.env.company.id}_account_tax_template_{trailing_xml_id}') + + @classmethod + def create_invoice(cls, **kwargs): + return cls.env['account.move'].with_context(edi_test_mode=True).create({ + 'move_type': 'out_invoice', + 'partner_id': cls.partner_a.id, + 'invoice_date': '2019-01-01', + 'date': '2019-01-01', + **kwargs, + 'invoice_line_ids': [(0, 0, { + 'product_id': cls.product_a.id, + 'price_unit': 1000.0, + **line_vals, + }) for line_vals in kwargs.get('invoice_line_ids', [])], + }) diff --git a/addons/l10n_es_edi_sii/tests/test_edi_web_services.py b/addons/l10n_es_edi_sii/tests/test_edi_web_services.py new file mode 100644 index 00000000000..03ef47c0d66 --- /dev/null +++ b/addons/l10n_es_edi_sii/tests/test_edi_web_services.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +from datetime import datetime + +from odoo.tests import tagged +from odoo import fields +from .common import TestEsEdiCommon + + +@tagged('external_l10n', 'post_install', '-at_install', '-standard', 'external') +class TestEdiWebServices(TestEsEdiCommon): + + @classmethod + def setUpClass(cls, chart_template_ref='l10n_es.account_chart_template_full', edi_format_ref='l10n_es_edi_sii.edi_es_sii'): + super().setUpClass(chart_template_ref=chart_template_ref, edi_format_ref=edi_format_ref) + + # Invoice name are tracked by the web-services so this constant tries to get a new unique invoice name at each + # execution. + cls.today = datetime.now() + cls.time_name = cls.today.strftime('%H%M%S') + + cls.out_invoice = cls.env['account.move'].create({ + 'name': f'INV{cls.time_name}', + 'move_type': 'out_invoice', + 'partner_id': cls.partner_a.id, + 'invoice_line_ids': [(0, 0, { + 'product_id': cls.product_a.id, + 'price_unit': 1000.0, + 'quantity': 5, + 'discount': 20.0, + 'tax_ids': [(6, 0, cls._get_tax_by_xml_id('s_iva21b').ids)], + })], + }) + cls.out_invoice.action_post() + + cls.in_invoice = cls.env['account.move'].create({ + 'name': f'BILL{cls.time_name}', + 'ref': f'REFBILL{cls.time_name}', + 'move_type': 'in_invoice', + 'partner_id': cls.partner_a.id, + 'invoice_date': fields.Date.to_string(cls.today.date()), + 'invoice_line_ids': [(0, 0, { + 'product_id': cls.product_a.id, + 'price_unit': 1000.0, + 'quantity': 5, + 'discount': 20.0, + 'tax_ids': [(6, 0, cls._get_tax_by_xml_id('p_iva10_bc').ids)], + })], + }) + cls.in_invoice.action_post() + + cls.moves = cls.out_invoice + cls.in_invoice + + def test_edi_aeat(self): + self.env.company.l10n_es_edi_tax_agency = 'aeat' + + self.moves.action_process_edi_web_services() + generated_files = self._process_documents_web_services(self.moves, {'es_sii'}) + self.assertTrue(generated_files) + self.assertRecordValues(self.out_invoice, [{'edi_state': 'sent'}]) + self.assertRecordValues(self.in_invoice, [{'edi_state': 'sent'}]) + + def test_edi_gipuzkoa(self): + self.env.company.l10n_es_edi_tax_agency = 'gipuzkoa' + + self.moves.action_process_edi_web_services() + generated_files = self._process_documents_web_services(self.moves, {'es_sii'}) + self.assertTrue(generated_files) + self.assertRecordValues(self.out_invoice, [{'edi_state': 'sent'}]) + self.assertRecordValues(self.in_invoice, [{'edi_state': 'sent'}]) + + def test_edi_bizkaia(self): + self.env.company.l10n_es_edi_tax_agency = 'bizkaia' + + self.moves.action_process_edi_web_services() + generated_files = self._process_documents_web_services(self.moves, {'es_sii'}) + self.assertTrue(generated_files) + self.assertRecordValues(self.out_invoice, [{'edi_state': 'sent'}]) + self.assertRecordValues(self.in_invoice, [{'edi_state': 'sent'}]) diff --git a/addons/l10n_es_edi_sii/tests/test_edi_xml.py b/addons/l10n_es_edi_sii/tests/test_edi_xml.py new file mode 100644 index 00000000000..cbaae0b6b7a --- /dev/null +++ b/addons/l10n_es_edi_sii/tests/test_edi_xml.py @@ -0,0 +1,1058 @@ +# coding: utf-8 +from .common import TestEsEdiCommon + +import json + +from freezegun import freeze_time +from unittest.mock import patch + +from odoo.tests import tagged + + +def mocked_l10n_es_edi_call_web_service_sign(edi_format, invoices, info_list): + return {inv: {'success': True} for inv in invoices} + + +@tagged('post_install_l10n', 'post_install', '-at_install') +class TestEdiXmls(TestEsEdiCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.certificate.write({ + 'date_start': '2019-01-01 01:00:00', + 'date_end': '2021-01-01 01:00:00', + }) + + def test_010_out_invoice_s_iva10b_s_iva21s(self): + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + partner_id=self.partner_a.id, + invoice_line_ids=[ + {'price_unit': 100.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva10b').ids)]}, + {'price_unit': 200.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva21s').ids)]}, + ], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + self.assertEqual(json_file, { + 'IDFactura': { + 'IDEmisorFactura': {'NIF': '59962470K'}, + 'NumSerieFacturaEmisor': 'INV/2019/00001', + 'FechaExpedicionFacturaEmisor': '01-01-2019', + }, + 'PeriodoLiquidacion': {'Ejercicio': '2019', 'Periodo': '01'}, + 'FacturaExpedida': { + 'TipoFactura': 'F1', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'DescripcionOperacion': 'manual', + 'TipoDesglose': { + 'DesgloseTipoOperacion': { + 'PrestacionServicios': { + 'Sujeta': { + 'NoExenta': { + 'TipoNoExenta': 'S1', + 'DesgloseIVA': { + 'DetalleIVA': [ + { + 'TipoImpositivo': 21.0, + 'BaseImponible': 200.0, + 'CuotaRepercutida': 42.0, + }, + ], + }, + }, + }, + }, + 'Entrega': { + 'Sujeta': { + 'NoExenta': { + 'TipoNoExenta': 'S1', + 'DesgloseIVA': { + 'DetalleIVA': [ + { + 'TipoImpositivo': 10.0, + 'BaseImponible': 100.0, + 'CuotaRepercutida': 10.0, + }, + ], + }, + }, + }, + }, + }, + }, + 'ImporteTotal': 352.0, + 'Contraparte': { + 'IDOtro': {'ID': 'BE0477472701', 'IDType': '02'}, + 'NombreRazon': 'partner_a', + }, + }, + }) + + def test_020_out_invoice_s_iva10b_s_iva0_ns(self): + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + partner_id=self.partner_b.id, + invoice_line_ids=[ + {'price_unit': 100.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva10b').ids)]}, + {'price_unit': 200.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva0_ns').ids)]}, + ], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + self.assertEqual(json_file, { + 'IDFactura': { + 'IDEmisorFactura': {'NIF': '59962470K'}, + 'NumSerieFacturaEmisor': 'INV/2019/00001', + 'FechaExpedicionFacturaEmisor': '01-01-2019', + }, + 'PeriodoLiquidacion': {'Ejercicio': '2019', 'Periodo': '01'}, + 'FacturaExpedida': { + 'TipoFactura': 'F1', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'DescripcionOperacion': 'manual', + 'TipoDesglose': { + 'DesgloseFactura': { + 'Sujeta': { + 'NoExenta': { + 'TipoNoExenta': 'S1', + 'DesgloseIVA': { + 'DetalleIVA': [ + { + 'TipoImpositivo': 10.0, + 'BaseImponible': 100.0, + 'CuotaRepercutida': 10.0 + }, + ], + }, + }, + }, + }, + }, + 'ImporteTotal': 110.0, + 'Contraparte': {'NombreRazon': 'partner_b', 'NIF': 'F35999705'}, + }, + }) + + def test_030_out_invoice_s_iva10b_s_req014_s_iva21s_s_req52(self): + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + partner_id=self.partner_a.id, + invoice_line_ids=[ + { + 'price_unit': 100.0, + 'tax_ids': [(6, 0, (self._get_tax_by_xml_id('s_iva10b') + self._get_tax_by_xml_id('s_req014')).ids)], + }, + { + 'price_unit': 200.0, + 'tax_ids': [(6, 0, (self._get_tax_by_xml_id('s_iva21s') + self._get_tax_by_xml_id('s_req52')).ids)], + }, + ], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + self.assertEqual(json_file, { + 'IDFactura': { + 'IDEmisorFactura': {'NIF': '59962470K'}, + 'NumSerieFacturaEmisor': 'INV/2019/00001', + 'FechaExpedicionFacturaEmisor': '01-01-2019', + }, + 'PeriodoLiquidacion': {'Ejercicio': '2019', 'Periodo': '01'}, + 'FacturaExpedida': { + 'TipoFactura': 'F1', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'DescripcionOperacion': 'manual', + 'TipoDesglose': { + 'DesgloseTipoOperacion': { + 'PrestacionServicios': { + 'Sujeta': { + 'NoExenta': { + 'TipoNoExenta': 'S1', + 'DesgloseIVA': { + 'DetalleIVA': [ + { + 'TipoImpositivo': 21.0, + 'BaseImponible': 200.0, + 'CuotaRepercutida': 42.0, + 'CuotaRecargoEquivalencia': 10.4, + 'TipoRecargoEquivalencia': 5.2 + } + ] + } + } + } + }, + 'Entrega': { + 'Sujeta': { + 'NoExenta': { + 'TipoNoExenta': 'S1', + 'DesgloseIVA': { + 'DetalleIVA': [ + { + 'TipoImpositivo': 10.0, + 'BaseImponible': 100.0, + 'CuotaRepercutida': 10.0, + 'CuotaRecargoEquivalencia': 1.4, + 'TipoRecargoEquivalencia': 1.4 + } + ] + } + } + } + } + } + }, + 'ImporteTotal': 363.8, + 'Contraparte': { + 'IDOtro': {'ID': 'BE0477472701', 'IDType': '02'}, + 'NombreRazon': 'partner_a', + }, + }, + }) + + def test_040_out_refund_s_iva10b_s_iva10b_s_iva21s(self): + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + move_type='out_refund', + partner_id=self.partner_a.id, + invoice_line_ids=[ + {'price_unit': 100.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva10b').ids)]}, + {'price_unit': 100.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva10b').ids)]}, + {'price_unit': 200.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva21s').ids)]}, + ], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + self.assertEqual(json_file, { + 'IDFactura': { + 'IDEmisorFactura': {'NIF': '59962470K'}, + 'NumSerieFacturaEmisor': 'RINV/2019/00001', + 'FechaExpedicionFacturaEmisor': '01-01-2019', + }, + 'PeriodoLiquidacion': {'Ejercicio': '2019', 'Periodo': '01'}, + 'FacturaExpedida': { + 'TipoFactura': 'R1', + 'TipoRectificativa': 'I', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'DescripcionOperacion': 'manual', + 'TipoDesglose': { + 'DesgloseTipoOperacion': { + 'PrestacionServicios': { + 'Sujeta': { + 'NoExenta': { + 'TipoNoExenta': 'S1', + 'DesgloseIVA': { + 'DetalleIVA': [ + { + 'TipoImpositivo': 21.0, + 'BaseImponible': -200.0, + 'CuotaRepercutida': -42.0 + } + ] + } + } + } + }, + 'Entrega': { + 'Sujeta': { + 'NoExenta': { + 'TipoNoExenta': 'S1', + 'DesgloseIVA': { + 'DetalleIVA': [ + { + 'TipoImpositivo': 10.0, + 'BaseImponible': -200.0, + 'CuotaRepercutida': -20.0 + } + ] + } + } + } + } + } + }, + 'ImporteTotal': -462.0, + 'Contraparte': { + 'IDOtro': {'ID': 'BE0477472701', 'IDType': '02'}, + 'NombreRazon': 'partner_a', + }, + }, + }) + + def test_050_out_invoice_s_iva0_sp_i_s_iva0_ic(self): + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + partner_id=self.partner_a.id, + invoice_line_ids=[ + {'price_unit': 100.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva0_sp_i').ids)]}, + {'price_unit': 200.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva0_ic').ids)]}, + ], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + self.assertEqual(json_file, { + 'IDFactura': { + 'IDEmisorFactura': {'NIF': '59962470K'}, + 'NumSerieFacturaEmisor': 'INV/2019/00001', + 'FechaExpedicionFacturaEmisor': '01-01-2019', + }, + 'PeriodoLiquidacion': {'Ejercicio': '2019', 'Periodo': '01'}, + 'FacturaExpedida': { + 'TipoFactura': 'F1', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'DescripcionOperacion': 'manual', + 'TipoDesglose': { + 'DesgloseTipoOperacion': { + 'PrestacionServicios': { + 'NoSujeta': { + 'ImportePorArticulos7_14_Otros': 100.0 + }, + }, + 'Entrega': { + 'Sujeta': { + 'Exenta': { + 'DetalleExenta': [ + { + 'BaseImponible': 200.0, + 'CausaExencion': 'E5', + }, + ], + }, + }, + }, + }, + }, + 'ImporteTotal': 300.0, + 'Contraparte': { + 'IDOtro': {'ID': 'BE0477472701', 'IDType': '02'}, + 'NombreRazon': 'partner_a', + }, + }, + }) + + def test_060_out_refund_s_iva0_sp_i_s_iva0_ic(self): + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + move_type='out_refund', + partner_id=self.partner_a.id, + invoice_line_ids=[ + {'price_unit': 100.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva0_sp_i').ids)]}, + {'price_unit': 200.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva0_ic').ids)]}, + ], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + self.assertEqual(json_file, { + 'IDFactura': { + 'IDEmisorFactura': {'NIF': '59962470K'}, + 'NumSerieFacturaEmisor': 'RINV/2019/00001', + 'FechaExpedicionFacturaEmisor': '01-01-2019', + }, + 'PeriodoLiquidacion': {'Ejercicio': '2019', 'Periodo': '01'}, + 'FacturaExpedida': { + 'TipoFactura': 'R1', + 'TipoRectificativa': 'I', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'DescripcionOperacion': 'manual', + 'TipoDesglose': { + 'DesgloseTipoOperacion': { + 'PrestacionServicios': { + 'NoSujeta': { + 'ImportePorArticulos7_14_Otros': -100.0 + }, + }, + 'Entrega': { + 'Sujeta': { + 'Exenta': { + 'DetalleExenta': [ + { + 'BaseImponible': -200.0, + 'CausaExencion': 'E5', + }, + ], + }, + }, + }, + }, + }, + 'ImporteTotal': -300.0, + 'Contraparte': { + 'IDOtro': {'ID': 'BE0477472701', 'IDType': '02'}, + 'NombreRazon': 'partner_a', + }, + }, + }) + + def test_070_out_invoice_s_iva_e_s_iva0_e(self): + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + partner_id=self.partner_a.id, + invoice_line_ids=[ + {'price_unit': 100.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva_e').ids)]}, + {'price_unit': 200.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva0_e').ids)]}, + ], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + self.assertEqual(json_file, { + 'IDFactura': { + 'IDEmisorFactura': {'NIF': '59962470K'}, + 'NumSerieFacturaEmisor': 'INV/2019/00001', + 'FechaExpedicionFacturaEmisor': '01-01-2019', + }, + 'PeriodoLiquidacion': {'Ejercicio': '2019', 'Periodo': '01'}, + 'FacturaExpedida': { + 'TipoFactura': 'F1', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'DescripcionOperacion': 'manual', + 'TipoDesglose': { + 'DesgloseTipoOperacion': { + 'PrestacionServicios': { + 'NoSujeta': { + 'ImportePorArticulos7_14_Otros': 100.0, + }, + }, + 'Entrega': { + 'Sujeta': { + 'Exenta': { + 'DetalleExenta': [ + { + 'BaseImponible': 200.0, + 'CausaExencion': 'E2', + }, + ], + }, + }, + }, + }, + }, + 'ImporteTotal': 300.0, + 'Contraparte': { + 'IDOtro': {'ID': 'BE0477472701', 'IDType': '02'}, + 'NombreRazon': 'partner_a', + }, + }, + }) + + def test_080_out_refund_s_iva0_sp_i_s_iva0_ic(self): + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + move_type='out_refund', + partner_id=self.partner_a.id, + invoice_line_ids=[ + {'price_unit': 100.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva0_sp_i').ids)]}, + {'price_unit': 200.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva0_ic').ids)]}, + ], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + self.assertEqual(json_file, { + 'IDFactura': { + 'IDEmisorFactura': {'NIF': '59962470K'}, + 'NumSerieFacturaEmisor': 'RINV/2019/00001', + 'FechaExpedicionFacturaEmisor': '01-01-2019', + }, + 'PeriodoLiquidacion': {'Ejercicio': '2019', 'Periodo': '01'}, + 'FacturaExpedida': { + 'TipoFactura': 'R1', + 'TipoRectificativa': 'I', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'DescripcionOperacion': 'manual', + 'TipoDesglose': { + 'DesgloseTipoOperacion': { + 'PrestacionServicios': { + 'NoSujeta': { + 'ImportePorArticulos7_14_Otros': -100.0, + }, + }, + 'Entrega': { + 'Sujeta': { + 'Exenta': { + 'DetalleExenta': [ + { + 'BaseImponible': -200.0, + 'CausaExencion': 'E5', + }, + ], + }, + }, + }, + }, + }, + 'ImporteTotal': -300.0, + 'Contraparte': { + 'IDOtro': {'ID': 'BE0477472701', 'IDType': '02'}, + 'NombreRazon': 'partner_a', + }, + }, + }) + + def test_085_out_refund_s_iva0_sp_i_s_iva0_ic_multi_currency(self): + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + move_type='out_refund', + partner_id=self.partner_a.id, + currency_id=self.currency_data['currency'].id, + invoice_line_ids=[ + {'price_unit': 200.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva0_sp_i').ids)]}, + {'price_unit': 400.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva0_ic').ids)]}, + ], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + self.assertEqual(json_file, { + 'IDFactura': { + 'IDEmisorFactura': {'NIF': '59962470K'}, + 'NumSerieFacturaEmisor': 'RINV/2019/00001', + 'FechaExpedicionFacturaEmisor': '01-01-2019', + }, + 'PeriodoLiquidacion': {'Ejercicio': '2019', 'Periodo': '01'}, + 'FacturaExpedida': { + 'TipoFactura': 'R1', + 'TipoRectificativa': 'I', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'DescripcionOperacion': 'manual', + 'TipoDesglose': { + 'DesgloseTipoOperacion': { + 'PrestacionServicios': { + 'NoSujeta': { + 'ImportePorArticulos7_14_Otros': -100.0, + }, + }, + 'Entrega': { + 'Sujeta': { + 'Exenta': { + 'DetalleExenta': [ + { + 'BaseImponible': -200.0, + 'CausaExencion': 'E5', + }, + ], + }, + }, + }, + }, + }, + 'ImporteTotal': -300.0, + 'Contraparte': { + 'IDOtro': {'ID': 'BE0477472701', 'IDType': '02'}, + 'NombreRazon': 'partner_a', + }, + }, + }) + + def test_090_in_invoice_p_iva10_bc_p_irpf19_p_iva21_sc_p_irpf19(self): + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + move_type='in_invoice', + ref='sup0001', + partner_id=self.partner_b.id, + l10n_es_registration_date='2019-01-02', + invoice_line_ids=[ + { + 'price_unit': 100.0, + 'tax_ids': [(6, 0, (self._get_tax_by_xml_id('p_iva10_bc') + self._get_tax_by_xml_id('p_irpf19')).ids)], + }, + { + 'price_unit': 200.0, + 'tax_ids': [(6, 0, (self._get_tax_by_xml_id('p_iva21_sc') + self._get_tax_by_xml_id('p_irpf19')).ids)], + }, + ], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + self.assertEqual(json_file, { + 'IDFactura': { + 'FechaExpedicionFacturaEmisor': '01-01-2019', + 'NumSerieFacturaEmisor': 'sup0001', + 'IDEmisorFactura': {'NIF': 'F35999705'} + }, + 'FacturaRecibida': { + 'TipoFactura': 'F1', + 'Contraparte': {'NombreRazon': 'partner_b', 'NIF': 'F35999705'}, + 'DescripcionOperacion': 'manual', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'ImporteTotal': 352.0, + 'FechaRegContable': '02-01-2019', + 'DesgloseFactura': { + 'DesgloseIVA': { + 'DetalleIVA': [ + {'BaseImponible': 100.0, 'CuotaSoportada': 10.0, 'TipoImpositivo': 10.0}, + {'BaseImponible': 200.0, 'CuotaSoportada': 42.0, 'TipoImpositivo': 21.0} + ] + } + }, + 'CuotaDeducible': 52.0 + }, + 'PeriodoLiquidacion': {'Periodo': '01', 'Ejercicio': '2019'} + }) + + def test_100_in_refund_p_iva10_bc(self): + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + move_type='in_refund', + ref='sup0001', + partner_id=self.partner_b.id, + l10n_es_registration_date='2019-01-02', + invoice_line_ids=[{'price_unit': 100.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('p_iva10_bc').ids)]}], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + self.assertEqual(json_file, { + 'IDFactura': { + 'FechaExpedicionFacturaEmisor': '01-01-2019', + 'NumSerieFacturaEmisor': 'sup0001', + 'IDEmisorFactura': {'NIF': 'F35999705'}, + }, + 'FacturaRecibida': { + 'TipoFactura': 'R4', + 'TipoRectificativa': 'I', + 'Contraparte': {'NombreRazon': 'partner_b', 'NIF': 'F35999705'}, + 'DescripcionOperacion': 'manual', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'ImporteTotal': -110.0, + 'FechaRegContable': '02-01-2019', + 'DesgloseFactura': { + 'DesgloseIVA': { + 'DetalleIVA': [ + {'BaseImponible': -100.0, 'CuotaSoportada': -10.0, 'TipoImpositivo': 10.0}, + ], + }, + }, + 'CuotaDeducible': -10.0, + }, + 'PeriodoLiquidacion': {'Periodo': '01', 'Ejercicio': '2019'}, + }) + + def test_110_in_invoice_p_iva10_bc_p_req014_p_iva21_sc_p_req52(self): + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + move_type='in_invoice', + ref='sup0001', + partner_id=self.partner_b.id, + l10n_es_registration_date='2019-01-02', + invoice_line_ids=[ + { + 'price_unit': 100.0, + 'tax_ids': [(6, 0, (self._get_tax_by_xml_id('p_iva10_bc') + self._get_tax_by_xml_id('p_req014')).ids)], + }, + { + 'price_unit': 200.0, + 'tax_ids': [(6, 0, (self._get_tax_by_xml_id('p_iva21_sc') + self._get_tax_by_xml_id('p_req52')).ids)], + }, + ], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + self.assertEqual(json_file, { + 'IDFactura': { + 'FechaExpedicionFacturaEmisor': '01-01-2019', + 'NumSerieFacturaEmisor': 'sup0001', + 'IDEmisorFactura': {'NIF': 'F35999705'}, + }, + 'FacturaRecibida': { + 'TipoFactura': 'F1', + 'Contraparte': {'NombreRazon': 'partner_b', 'NIF': 'F35999705'}, + 'DescripcionOperacion': 'manual', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'ImporteTotal': 363.8, + 'FechaRegContable': '02-01-2019', + 'DesgloseFactura': { + 'DesgloseIVA': { + 'DetalleIVA': [ + { + 'BaseImponible': 100.0, + 'CuotaSoportada': 10.0, + 'TipoImpositivo': 10.0, + 'CuotaRecargoEquivalencia': 1.4, + 'TipoRecargoEquivalencia': 1.4, + }, + { + 'BaseImponible': 200.0, + 'CuotaSoportada': 42.0, + 'TipoImpositivo': 21.0, + 'CuotaRecargoEquivalencia': 10.4, + 'TipoRecargoEquivalencia': 5.2, + }, + ], + }, + }, + 'CuotaDeducible': 52.0, + }, + 'PeriodoLiquidacion': {'Periodo': '01', 'Ejercicio': '2019'}, + }) + + def test_120_in_invoice_p_iva21_sp_ex(self): + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + move_type='in_invoice', + ref='sup0001', + partner_id=self.partner_b.id, + l10n_es_registration_date='2019-01-02', + invoice_line_ids=[{'price_unit': 100.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('p_iva21_sp_ex').ids)]}], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + self.assertEqual(json_file, { + 'IDFactura': { + 'FechaExpedicionFacturaEmisor': '01-01-2019', + 'NumSerieFacturaEmisor': 'sup0001', + 'IDEmisorFactura': {'NIF': 'F35999705'}, + }, + 'FacturaRecibida': { + 'TipoFactura': 'F1', + 'Contraparte': {'NombreRazon': 'partner_b', 'NIF': 'F35999705'}, + 'DescripcionOperacion': 'manual', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'ImporteTotal': 121.0, + 'FechaRegContable': '02-01-2019', + 'DesgloseFactura': { + 'InversionSujetoPasivo': { + 'DetalleIVA': [{ + 'BaseImponible': 100.0, + 'CuotaSoportada': 21.0, + 'TipoImpositivo': 21.0, + }], + }, + }, + 'CuotaDeducible': 21.0, + }, + 'PeriodoLiquidacion': {'Periodo': '01', 'Ejercicio': '2019'}, + }) + + def test_130_in_invoice_p_iva0_ns_p_iva10_bc(self): + # TODO make it work + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + move_type='in_invoice', + ref='sup0001', + partner_id=self.partner_b.id, + l10n_es_registration_date='2019-01-02', + invoice_line_ids=[ + {'price_unit': 100.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('p_iva0_ns').ids)]}, + {'price_unit': 200.0, 'tax_ids': [(6, 0, self._get_tax_by_xml_id('p_iva10_bc').ids)]}, + ], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + self.assertEqual(json_file, { + 'IDFactura': { + 'FechaExpedicionFacturaEmisor': '01-01-2019', + 'NumSerieFacturaEmisor': 'sup0001', + 'IDEmisorFactura': {'NIF': 'F35999705'}, + }, + 'FacturaRecibida': { + 'TipoFactura': 'F1', + 'Contraparte': {'NombreRazon': 'partner_b', 'NIF': 'F35999705'}, + 'DescripcionOperacion': 'manual', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'ImporteTotal': 320.0, + 'FechaRegContable': '02-01-2019', + 'DesgloseFactura': { + 'DesgloseIVA': { + 'DetalleIVA': [ + {'BaseImponible': 100.0}, + {'BaseImponible': 200.0, 'TipoImpositivo': 10.0, 'CuotaSoportada': 20.0}, + ], + }, + }, + 'CuotaDeducible': 20.0, + }, + 'PeriodoLiquidacion': {'Periodo': '01', 'Ejercicio': '2019'}, + }) + + def test_140_out_invoice_s_iva10b_s_irpf1(self): + # TODO: debug + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + partner_id=self.partner_b.id, + invoice_line_ids=[ + { + 'price_unit': 100.0, + 'tax_ids': [(6, 0, (self._get_tax_by_xml_id('s_iva10b') + self._get_tax_by_xml_id('s_irpf1')).ids)], + }, + ], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + + self.assertEqual(json_file, { + 'IDFactura': { + 'IDEmisorFactura': {'NIF': '59962470K'}, + 'NumSerieFacturaEmisor': 'INV/2019/00001', + 'FechaExpedicionFacturaEmisor': '01-01-2019', + }, + 'PeriodoLiquidacion': {'Ejercicio': '2019', 'Periodo': '01'}, + 'FacturaExpedida': { + 'TipoFactura': 'F1', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'DescripcionOperacion': 'manual', + 'TipoDesglose': { + 'DesgloseFactura': { + 'Sujeta': { + 'NoExenta': { + 'TipoNoExenta': 'S1', + 'DesgloseIVA': { + 'DetalleIVA': [ + { + 'TipoImpositivo': 10.0, + 'BaseImponible': 100.0, + 'CuotaRepercutida': 10.0, + }, + ], + }, + }, + }, + }, + }, + 'ImporteTotal': 110.0, + 'Contraparte': {'NombreRazon': 'partner_b', 'NIF': 'F35999705'}, + }, + }) + + def test_150_in_invoice_p_iva10_bc_p_irpf1(self): + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + move_type='in_invoice', + ref='sup0001', + partner_id=self.partner_b.id, + l10n_es_registration_date='2019-01-02', + invoice_line_ids=[ + { + 'price_unit': 100.0, + 'tax_ids': [(6, 0, (self._get_tax_by_xml_id('p_iva10_bc') + self._get_tax_by_xml_id('p_irpf1')).ids)], + }, + ], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + self.assertEqual(json_file, { + 'IDFactura': { + 'FechaExpedicionFacturaEmisor': '01-01-2019', + 'NumSerieFacturaEmisor': 'sup0001', + 'IDEmisorFactura': {'NIF': 'F35999705'}, + }, + 'FacturaRecibida': { + 'TipoFactura': 'F1', + 'Contraparte': {'NombreRazon': 'partner_b', 'NIF': 'F35999705'}, + 'DescripcionOperacion': 'manual', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'ImporteTotal': 110.0, + 'FechaRegContable': '02-01-2019', + 'DesgloseFactura': { + 'DesgloseIVA': { + 'DetalleIVA': [ + { + 'BaseImponible': 100.0, + 'CuotaSoportada': 10.0, + 'TipoImpositivo': 10.0, + }, + ], + }, + }, + 'CuotaDeducible': 10.0, + }, + 'PeriodoLiquidacion': {'Periodo': '01', 'Ejercicio': '2019'}, + }) + + def test_160_in_refund_p_iva10_bc_p_irpf1(self): + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + move_type='in_refund', + ref='sup0001', + partner_id=self.partner_b.id, + l10n_es_registration_date='2019-01-02', + invoice_line_ids=[ + { + 'price_unit': 100.0, + 'tax_ids': [(6, 0, (self._get_tax_by_xml_id('p_iva10_bc') + self._get_tax_by_xml_id('p_irpf1')).ids)], + }, + ], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + self.assertEqual(json_file, { + 'IDFactura': { + 'FechaExpedicionFacturaEmisor': '01-01-2019', + 'NumSerieFacturaEmisor': 'sup0001', + 'IDEmisorFactura': {'NIF': 'F35999705'}, + }, + 'FacturaRecibida': { + 'TipoFactura': 'R4', + 'TipoRectificativa': 'I', + 'Contraparte': {'NombreRazon': 'partner_b', 'NIF': 'F35999705'}, + 'DescripcionOperacion': 'manual', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'ImporteTotal': -110.0, + 'FechaRegContable': '02-01-2019', + 'DesgloseFactura': { + 'DesgloseIVA': { + 'DetalleIVA': [ + { + 'BaseImponible': -100.0, + 'CuotaSoportada': -10.0, + 'TipoImpositivo': 10.0, + }, + ], + }, + }, + 'CuotaDeducible': -10.0, + }, + 'PeriodoLiquidacion': {'Periodo': '01', 'Ejercicio': '2019'}, + }) + + def test_165_in_refund_p_iva10_bc_p_irpf1_multi_currency(self): + with freeze_time(self.frozen_today), \ + patch('odoo.addons.l10n_es_edi_sii.models.account_edi_format.AccountEdiFormat._l10n_es_edi_call_web_service_sign', + new=mocked_l10n_es_edi_call_web_service_sign): + invoice = self.create_invoice( + move_type='in_refund', + ref='sup0001', + partner_id=self.partner_b.id, + currency_id=self.currency_data['currency'].id, + l10n_es_registration_date='2019-01-02', + invoice_line_ids=[ + { + 'price_unit': 200.0, + 'tax_ids': [(6, 0, (self._get_tax_by_xml_id('p_iva10_bc') + self._get_tax_by_xml_id('p_irpf1')).ids)], + }, + ], + ) + invoice.action_post() + + generated_files = self._process_documents_web_services(invoice, {'es_sii'}) + self.assertTrue(generated_files) + + json_file = json.loads(generated_files[0].decode())[0] + self.assertEqual(json_file, { + 'IDFactura': { + 'FechaExpedicionFacturaEmisor': '01-01-2019', + 'NumSerieFacturaEmisor': 'sup0001', + 'IDEmisorFactura': {'NIF': 'F35999705'}, + }, + 'FacturaRecibida': { + 'TipoFactura': 'R4', + 'TipoRectificativa': 'I', + 'Contraparte': {'NombreRazon': 'partner_b', 'NIF': 'F35999705'}, + 'DescripcionOperacion': 'manual', + 'ClaveRegimenEspecialOTrascendencia': '01', + 'ImporteTotal': -110.0, + 'FechaRegContable': '02-01-2019', + 'DesgloseFactura': { + 'DesgloseIVA': { + 'DetalleIVA': [ + { + 'BaseImponible': -100.0, + 'CuotaSoportada': -10.0, + 'TipoImpositivo': 10.0, + }, + ], + }, + }, + 'CuotaDeducible': -10.0, + }, + 'PeriodoLiquidacion': {'Periodo': '01', 'Ejercicio': '2019'}, + }) diff --git a/addons/l10n_es_edi_sii/views/account_tax_views.xml b/addons/l10n_es_edi_sii/views/account_tax_views.xml new file mode 100644 index 00000000000..dbd54b77e9f --- /dev/null +++ b/addons/l10n_es_edi_sii/views/account_tax_views.xml @@ -0,0 +1,20 @@ + + + + + account.tax.form.inherit.l10n_es_edi + account.tax + + + + + + + + + + + diff --git a/addons/l10n_es_edi_sii/views/l10n_es_edi_certificate_views.xml b/addons/l10n_es_edi_sii/views/l10n_es_edi_certificate_views.xml new file mode 100644 index 00000000000..a22e3292437 --- /dev/null +++ b/addons/l10n_es_edi_sii/views/l10n_es_edi_certificate_views.xml @@ -0,0 +1,60 @@ + + + + + + l10n_es_edi.certificate.form + l10n_es_edi.certificate + +
+ + + + + + +
+
+
+ + + l10n_es_edi.certificate.tree + l10n_es_edi.certificate + + + + + + + + + + + Certificates for EDI invoices on Spain + l10n_es_edi.certificate + tree,form + +

Create the first certificate

+
+
+ + + + + +
+
diff --git a/addons/l10n_es_edi_sii/views/res_config_settings_views.xml b/addons/l10n_es_edi_sii/views/res_config_settings_views.xml new file mode 100644 index 00000000000..9f1d206e600 --- /dev/null +++ b/addons/l10n_es_edi_sii/views/res_config_settings_views.xml @@ -0,0 +1,42 @@ + + + + res.config.settings.view.form.inherit.l10n.es + res.config.settings + + + +

Spain Localization

+
+
+ + + + +
+ +
+ Registro de Libros connection SII + +
+
+ +
+ Check this box if test env: +

+ Go to Configuration > Certificates [ES] to add your certificate. +

+
+
+
+
+
+ + + + +