From 5d4e0210a08aca83cc8e90d7f83795d623ac2dd4 Mon Sep 17 00:00:00 2001 From: Josse Colpaert Date: Wed, 1 Sep 2021 09:24:48 +0000 Subject: [PATCH] [FW][ADD] l10n_es_edi_sii: send invoices to sii for G417 Llevanza de los libros MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. closes odoo/odoo#76615 Task: 2492978 Forward-port-of: https://github.com/odoo/odoo/pull/70302 X-original-commit: ff4cb972b6a8b61caeac13d8eb5c6a4d43a62ccb Signed-off-by: Laurent Smet --- addons/account_edi/models/account_move.py | 62 +- addons/l10n_es_edi_sii/__init__.py | 35 + addons/l10n_es_edi_sii/__manifest__.py | 50 + .../l10n_es_edi_sii/data/account_edi_data.xml | 11 + .../l10n_es_edi_sii/data/account_tax_data.xml | 418 +++++++ .../l10n_es_edi_sii/data/res_partner_data.xml | 6 + .../demo/certificates/sello_entidad_act.p12 | Bin 0 -> 4834 bytes addons/l10n_es_edi_sii/demo/demo_company.xml | 11 + addons/l10n_es_edi_sii/models/__init__.py | 8 + .../models/account_edi_format.py | 666 +++++++++++ addons/l10n_es_edi_sii/models/account_move.py | 36 + addons/l10n_es_edi_sii/models/account_tax.py | 55 + .../models/l10n_es_edi_certificate.py | 87 ++ addons/l10n_es_edi_sii/models/res_company.py | 44 + .../models/res_config_settings.py | 11 + .../security/ir.model.access.csv | 2 + addons/l10n_es_edi_sii/tests/__init__.py | 4 + addons/l10n_es_edi_sii/tests/common.py | 77 ++ .../tests/test_edi_web_services.py | 78 ++ addons/l10n_es_edi_sii/tests/test_edi_xml.py | 1058 +++++++++++++++++ .../views/account_tax_views.xml | 20 + .../views/l10n_es_edi_certificate_views.xml | 60 + .../views/res_config_settings_views.xml | 42 + 23 files changed, 2831 insertions(+), 10 deletions(-) create mode 100644 addons/l10n_es_edi_sii/__init__.py create mode 100644 addons/l10n_es_edi_sii/__manifest__.py create mode 100644 addons/l10n_es_edi_sii/data/account_edi_data.xml create mode 100644 addons/l10n_es_edi_sii/data/account_tax_data.xml create mode 100644 addons/l10n_es_edi_sii/data/res_partner_data.xml create mode 100644 addons/l10n_es_edi_sii/demo/certificates/sello_entidad_act.p12 create mode 100644 addons/l10n_es_edi_sii/demo/demo_company.xml create mode 100644 addons/l10n_es_edi_sii/models/__init__.py create mode 100644 addons/l10n_es_edi_sii/models/account_edi_format.py create mode 100644 addons/l10n_es_edi_sii/models/account_move.py create mode 100644 addons/l10n_es_edi_sii/models/account_tax.py create mode 100644 addons/l10n_es_edi_sii/models/l10n_es_edi_certificate.py create mode 100644 addons/l10n_es_edi_sii/models/res_company.py create mode 100644 addons/l10n_es_edi_sii/models/res_config_settings.py create mode 100644 addons/l10n_es_edi_sii/security/ir.model.access.csv create mode 100644 addons/l10n_es_edi_sii/tests/__init__.py create mode 100644 addons/l10n_es_edi_sii/tests/common.py create mode 100644 addons/l10n_es_edi_sii/tests/test_edi_web_services.py create mode 100644 addons/l10n_es_edi_sii/tests/test_edi_xml.py create mode 100644 addons/l10n_es_edi_sii/views/account_tax_views.xml create mode 100644 addons/l10n_es_edi_sii/views/l10n_es_edi_certificate_views.xml create mode 100644 addons/l10n_es_edi_sii/views/res_config_settings_views.xml 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 0000000000000000000000000000000000000000..ad2d5239630ec816df96ada589f4abb02788da1d GIT binary patch literal 4834 zcmZWr2Q*yY-o3*h7&XHXy+;Y7M(=`X(Mv=b%tVM@qKhtw5}hCk(QDL1@1jNoi7q;$ zi_SOk{_nl-Ti-hCl;7U_?0wFGAY3#&2zMRRmvIUI*##j0VdVsYx8XtH4LB_>!M|aQIWhQFpvY4( zMZ3UTSXj3Ja1glHg)5Zol7K`a*nPJ1LERHieILV={@RKItr1!VhXQrzW#M2 zWK?GKV!S|tT+8N`fAY!Re$zekSqXY5pdv$tG9Y*}gt88YWxLsp&Bs^9c;nRScmMvi zzRw_g7@2pkO5fjQ_v!tg*~Ll);fYeX6jmfJ@z!-GdPh6`lX@Dr85Vbnh_1g8&dC;h z@?iza%mA(Dki~+KbtUg#Lo>rh!TMg4j<$>v7ii1T;nYf*Oq%UD z5!Yqnl&ns9iMNsXJo>o5eiZ5F?!C+IH7ZhTdrjKX&Q%P{rZ3$*@Hy>aS~pp>CLm17 z=rfPUR+D+bgCO576iQ~#*`%u6Q(sj_P2~CevP3DTQL40&p-3lxmfWJfvz30nW+e{B z-q~vj!AY}17Dp)l8t-IKj2#f>^YW_PDRxL@TB{31FeMB*lLLT6;1WfEsYMx~3BrdP==`-7NB{I^!2P`P%$Re!px=sJfK&F8?6 zczyk!9!@*#YQ0Dg*0huTw+6U;reRsuujxCWgEp1hR6o2NKh*d}ILpl{sd`0xkmEY6 zPbu@$!!8-xG5O}>`26BhnPd4oU69KAL}=5#RIZw?=lW(SRI-*OV#;PFn|YEn6pYm})@ z8ZCb%b_TLdi~PC!zGd8={0mygWi~_NspEHfrDEM<5X{MZG}%PLM`E`&4_iiN-7`uu z(M34>K>WFhTlESg|C?Grtje`XWQMOT%uGh$oOA7=s93r%dgKpYklnJVzqW$I%;^(G z&OaF?kc5qk@C=~3gojFtIBXqw7J&fg*k~V)S z{+hK5CS6U4Rc`QwcvrWBkEnFk(slTfL1yDE`MU$Qw)AqY^9M-MveB9nz-35Q!Py4e zuU$l7@b?t~qt5Zk*=`$;okvrPz)bbxiQ+y~QEO6w5hO%D^v50%*^_!UH7z4rl`U zfCeCcgGga8ML+?w)&=eXV*jh%2ShMCcmPHWqX|d>4=_pvK<{Q$1+*}@+&{GeruwT> z#;E@0sAHNIX4bq>D`IqtH@w1)k1Akt!#%=`f4zAzJns#!g~?OKRGOF!6U_WKOY5dm z{Eyq;Q~bS&6ku?p_}960f%||cAc#3JKLE$9RsQkQ$LLG|TL6XW_JBJk&l|IH2V4L* z!14xl!PJ%*{2rz&05&&_Er7(JRv6qGaKzx&7@QGs#f%sDYPfd>%={O94};pPMpI$ardCr?6-3U$$cxJf!v_MYIa(5ZuEO z>p$_qF}8}-jgjLQ1{SPm-tDrV`O@!it%z2i^j!H&(dVx$Mg=EvgIyfym#KG)!=xAE zcm6`bk4PE9t=B_lE!^{58TMtwYm+A> zBi3A{kVy@F3F&}%i)!PhwmFEyZGdfr3F=X)!wUi4&-Y@aMJ+NTI8%jD)#(&vQ_m!6c>hD->Z$2_k1=3+jA^7xWcgkOCC$x^v*cqjp8roEPUj?TM z2f$teEblr^*iC&W-({q{F6H=X-u*Gx-=))(7wrY6jUImJ5=Jq~XNchQ!2g|Gv|bqgyiF=3mdY$}|0eN?E-=iQ`0a5wi3gKq!sS3+a=U`g<| zs)bYzQj#({(Q=$)7xXW>%D5gxl#W@XAh?m;8a%&p{{_Tizv~!BXVvIM9&G;d0Hs6D zA|Ce1>{#NBhHvBq8lgQ@e9)2h{H1ejA5SZZ`+gQhwzWo|RsT_WTI>)EcA8}6)$U8o z8J4{~+XhuIn#?s}WIc&|hHYXx8Eq965?Do##__EWk<-zynU5g<*h`eQlvq2FW}K*O zSPAd2RXWhT4zvb`Fhj=e&|<*g?K3B9g}eGBvKbHohQ)$g#FjnN#< zN}uFlpIP5LMu{5pK?5O?+H##v7oKv7uNIZ_6>EyF5kDQjw;97&z4! z{8+;~`+!t~zF}BS&do;pmc&t|v3hi+S=?(flRP-J>Ulldd%Rbsb)n;jBzo)m1OLIc za`r=E&S$;N9{x%jr9G$2<;2_NU{h1%O49B>=0I}?(fi)8(|K@S`y2At%IKa37DjHvc6kKS&8AbZ{( zYae;w9Vt>Rt$T0iAKGu6M&+XY(vzNe|KfebwAHS@$x|X=YljZhZRMicSoWOU&!Hq$ zLE?0t`MU)Cy!AfKbZd*4NK2pwp=rJPbZAlnPMS2cKGk%aW-QFfx!{cS1Z;DEuEt;i`=vJw1!OiBR<#tcwTjTBM>Lnw^LLz zB3^-q%N9!byhbdquz4iZN9ML(PhG$2ri)>0X_zvlCH?jc9QMHdg)c1r_5g|>=_iH% zPBvKM@mS~=JG#18u92wr^j%WCIDJ@!CS`j!CstznxvO>Z(#jwZE!1E^7H;{7&Ecfz zkp#>8GmkHnwZ>Fy@816!x!SkZE<{V8t*$Ls67CbK_@l}!vUb){$JAebGC?R~-*6Uu@ngi%riKwv>{-@C_7Do8Hwlbm()S z>l39uZyIo%0YV0Z?d1X6d*@ys6Pw8*!>VDTVf~o9-awt#1{}LK#n3NvJwGd; zub}G=FL+$?yCbtql?#BxEf<>T>$I^H4oA*{sH~_vO|u z?b_iqc5*seWYA7d^(=QlK+}gH%GcMgOoAJqha|sh`uy6TMi)wMp=VRe2MB*|Ln~Ap z^A$-+Z_tQdLtkZU?S-(8;+N=lW@hirsC(8=i&$l&$G857-}Z?N{Mv#RFkdO4k7HbK z*wjC@xeI40cuOaIB%w)-k0&^1GRRVE;5|(|hnFbywU^h5cIm=%z<=(p4H$TYCg-z% z{0{({e z9$MJJ;>rgGQ?Z_Q?ZI*WpqjW^!g^sz5ndG@VQW_941zuoeZiL@n^UKjO(scvws;HF zsv@HnbS>V?TL2sQZgPQ73(1q0!OjoqihX+8prb7$z4Wq}+J@1t@ts9G(Hg>#?zvrU za=`mhco)sQJk0M-SQm4Vld+cbkXNP zF*ZUGgUg)$xPEfy+EN!XR^7swP#006_e1OQTKkOTw_Q-KS5?Bzo&AIG`)~$09xe_y zF*ep6Fw~MjH(`WSc=;t;QQzut5mV=;Mgy4i+E + + + + + + + + + + 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. +

+
+
+
+
+
+ + + + +