# -*- coding: utf-8 -*- # The COPYRIGHT file at the top level of this repository contains the full # copyright notices and license terms. from decimal import Decimal from logging import getLogger from operator import attrgetter from datetime import date from trytond.i18n import gettext from trytond.model import Model from trytond.exceptions import UserError from . import tools _logger = getLogger(__name__) _DATE_FMT = '%d-%m-%Y' _FIRST_SEMESTER_RECORD_DESCRIPTION = "Registro del Primer semestre" RECTIFIED_KINDS = frozenset({'R1', 'R2', 'R3', 'R4', 'R5'}) OTHER_ID_TYPES = frozenset({'02', '03', '04', '05', '06', '07'}) SEMESTER1_ISSUED_SPECIALKEY = '16' SEMESTER1_RECIEVED_SPECIALKEY = '14' class BaseInvoiceMapper(Model): year = attrgetter('move.period.start_date.year') period = attrgetter('move.period.start_date.month') nif = attrgetter('company.party.sii_vat_code') issue_date = attrgetter('invoice_date') invoice_kind = attrgetter('sii_operation_key') rectified_invoice_kind = tools.fixed_value('I') def not_exempt_kind(self, tax): return attrgetter('sii_subjected_key')(tax) def exempt_kind(self, tax): return attrgetter('sii_exemption_cause')(tax) def not_subject(self, invoice): base = 0 for line in invoice.lines: for tax in line.taxes: if (tax.sii_exemption_cause == 'NotSubject' and not tax.service): base += attrgetter('amount')(line) return base def counterpart_nif(self, invoice): nif = '' if invoice.sii_operation_key == 'F5': # Assume that the company party is configured correctly. nif = invoice.company.party.tax_identifier.code else: if invoice.party.tax_identifier: nif = invoice.party.tax_identifier.code elif invoice.party.identifiers: nif = invoice.party.identifiers[0].code if nif.startswith('ES'): nif = nif[2:] return nif def get_tax_amount(self, tax): val = attrgetter('company_amount')(tax) return val def get_tax_base(self, tax): val = attrgetter('company_base')(tax) return val def get_invoice_total(self, invoice): taxes = self.total_invoice_taxes(invoice) taxes_base = 0 taxes_amount = 0 taxes_surcharge = 0 taxes_used = {} for tax in taxes: base = self.get_tax_base(tax) taxes_amount += self.get_tax_amount(tax) taxes_surcharge += self.tax_equivalence_surcharge_amount(tax) or 0 parent = tax.tax.parent if tax.tax.parent else tax.tax if (parent.id in list(taxes_used.keys()) and base == taxes_used[parent.id]): continue taxes_base += base taxes_used[parent.id] = base return (taxes_amount + taxes_base + taxes_surcharge) def counterpart_id_type(self, invoice): if invoice.sii_operation_key == 'F5': return invoice.company.party.sii_identifier_type else: if (invoice.sii_received_key == '09' and invoice.party.sii_identifier_type != '02'): raise UserError(gettext('aeat_sii.msg_wrong_identifier_type', invoice=invoice.number, party=invoice.party.rec_name)) for tax in invoice.taxes: if (self.exempt_kind(tax.tax) == 'E5' and invoice.party.sii_identifier_type != '02'): raise UserError(gettext( 'aeat_sii.msg_wrong_identifier_type', invoice=invoice.number, party=invoice.party.rec_name)) return invoice.party.sii_identifier_type counterpart_id = counterpart_nif total_amount = get_invoice_total tax_rate = attrgetter('tax.rate') tax_base = get_tax_base tax_amount = get_tax_amount def counterpart_name(self, invoice): if invoice.sii_operation_key == 'F5': return tools.unaccent(invoice.company.party.name) else: return tools.unaccent(invoice.party.name) def counterpart_country(self, invoice): return (invoice.invoice_address.country.code if invoice.invoice_address.country else '') def serial_number(self, invoice): return invoice.number if invoice.type == 'out' else (invoice.reference or '') def taxes(self, invoice): return [invoice_tax for invoice_tax in invoice.taxes if ( invoice_tax.tax.tax_used and not invoice_tax.tax.recargo_equivalencia)] def total_invoice_taxes(self, invoice): return [invoice_tax for invoice_tax in invoice.taxes if ( invoice_tax.tax.invoice_used and not invoice_tax.tax.recargo_equivalencia)] def _tax_equivalence_surcharge(self, invoice_tax): surcharge_tax = None for invoicetax in invoice_tax.invoice.taxes: if (invoicetax.tax.recargo_equivalencia and invoice_tax.tax.recargo_equivalencia_related_tax == invoicetax.tax and invoicetax.base == invoicetax.base.copy_sign(invoice_tax.base)): surcharge_tax = invoicetax break return surcharge_tax def tax_equivalence_surcharge_rate(self, invoice_tax): surcharge_tax = self._tax_equivalence_surcharge(invoice_tax) if surcharge_tax: return self.tax_rate(surcharge_tax) def tax_equivalence_surcharge_amount(self, invoice_tax): surcharge_tax = self._tax_equivalence_surcharge(invoice_tax) if surcharge_tax: return self.tax_amount(surcharge_tax) def _build_period(self, invoice): return { 'Ejercicio': self.year(invoice), 'Periodo': tools._format_period(self.period(invoice)), } def _build_invoice_id(self, invoice): number = self.serial_number(invoice) ret = { 'IDEmisorFactura': self._build_issuer_id(invoice), 'NumSerieFacturaEmisor': number, 'FechaExpedicionFacturaEmisor': self.issue_date(invoice).strftime(_DATE_FMT), } if self.invoice_kind(invoice) == 'F4': first_invoice = invoice.simplified_serial_number('first') last_invoice = invoice.simplified_serial_number('last') ret['NumSerieFacturaEmisor'] = number + first_invoice ret['NumSerieFacturaEmisorResumenFin'] = number + last_invoice return ret def _build_counterpart(self, invoice): ret = { 'NombreRazon': self.counterpart_name(invoice), } id_type = self.counterpart_id_type(invoice) if id_type and id_type in OTHER_ID_TYPES: ret['IDOtro'] = { 'IDType': id_type, 'CodigoPais': self.counterpart_country(invoice), 'ID': self.counterpart_id(invoice), } else: ret['NIF'] = self.counterpart_nif(invoice) return ret def _description(self, invoice): description = '' if invoice.description: description = tools.unaccent(invoice.description) if invoice.lines and invoice.lines[0].description: description = tools.unaccent(invoice.lines[0].description) description = self.serial_number(invoice) return (description if not self._is_first_semester(invoice) else _FIRST_SEMESTER_RECORD_DESCRIPTION ) def build_query_filter(self, year=None, period=None, last_invoice=None): # TODO: IDFactura, Contraparte, # FechaPresentacion, FechaCuadre, FacturaModificada, # EstadoCuadre result = { 'PeriodoLiquidacion': { 'Ejercicio': year, 'Periodo': tools._format_period(period), } } if last_invoice: result['ClavePaginacion'] = last_invoice return result class IssuedInvoiceMapper(BaseInvoiceMapper): """ Tryton Issued Invoice to AEAT mapper """ __name__ = 'aeat.sii.issued.invoice.mapper' specialkey_or_trascendence = attrgetter('sii_issued_key') def _is_first_semester(self, invoice): return self.specialkey_or_trascendence(invoice) == \ SEMESTER1_ISSUED_SPECIALKEY def build_delete_request(self, invoice): return { 'PeriodoLiquidacion': self._build_period(invoice), 'IDFactura': self._build_invoice_id(invoice), } def build_submit_request(self, invoice): request = self.build_delete_request(invoice) request['FacturaExpedida'] = self.build_issued_invoice(invoice) return request def _build_issuer_id(self, invoice): return { 'NIF': self.nif(invoice), } def build_taxes(self, tax): if not tax: return {} res = { 'TipoImpositivo': tools._rate_to_percent(self.tax_rate(tax)), 'BaseImponible': self.tax_base(tax), 'CuotaRepercutida': self.tax_amount(tax) } # In case base is 0, return only the tax, not the possible IRPF. if self.tax_base(tax) == Decimal(0): return res if self.tax_equivalence_surcharge_rate(tax): res['TipoRecargoEquivalencia'] = ( tools._rate_to_percent(self.tax_equivalence_surcharge_rate( tax))) if self.tax_equivalence_surcharge_amount(tax): res['CuotaRecargoEquivalencia'] = ( self.tax_equivalence_surcharge_amount(tax)) return res def location_rules(self, invoice): base = 0 for line in invoice.lines: for tax in line.taxes: if (tax.sii_issued_key == '08' or (tax.sii_exemption_cause == 'NotSubject' and tax.service)): base += attrgetter('amount')(line) return base def build_issued_invoice(self, invoice): ret = { 'TipoFactura': self.invoice_kind(invoice), # TODO: FacturasAgrupadas # TODO: FacturasRectificadas # TODO: FechaOperacion 'ClaveRegimenEspecialOTrascendencia': self.specialkey_or_trascendence(invoice), # TODO: ClaveRegimenEspecialOTrascendenciaAdicional1 # TODO: ClaveRegimenEspecialOTrascendenciaAdicional2 # TODO: NumRegistroAcuerdoFacturacion 'ImporteTotal': self.total_amount(invoice), # TODO: BaseImponibleACoste 'DescripcionOperacion': self._description(invoice), # TODO: RefExterna # TODO: FacturaSimplificadaArticulos7.2_7.3 # TODO: EntidadSucedida # TODO: RegPrevioGGEEoREDEMEoCompetencia # TODO: Macrodato # TODO: DatosInmueble # TODO: ImporteTransmisionInmueblesSujetoAIVA # TODO: EmitidaPorTercerosODestinatario # TODO: FacturacionDispAdicinalTerceraYsextayDelMercadoOrganizadoDelGas # TODO: VariosDestinatarios # TODO: Cupon # TODO: FacturaSinIdentifDestinatarioArticulo6.1.d 'TipoDesglose': {}, } self._update_counterpart(ret, invoice) must_detail_op = (ret.get('Contraparte', {}) and ( 'IDOtro' in ret['Contraparte'] or ('NIF' in ret['Contraparte'] and ret['Contraparte']['NIF'].startswith('N'))) ) detail = { 'Sujeta': {}, 'NoSujeta': {} } if must_detail_op: ret['TipoDesglose'].update({ 'DesgloseTipoOperacion': { 'Entrega': detail, 'PrestacionServicios': detail, } }) else: ret['TipoDesglose'].update({ 'DesgloseFactura': detail }) taxes = self.taxes(invoice) for tax in taxes: exempt_kind = self.exempt_kind(tax.tax) not_exempt_kind = self.not_exempt_kind(tax.tax) if (not_exempt_kind in ('S2', 'S3') and 'NIF' not in ret.get('Contraparte', {})): raise UserError(gettext('aeat_sii.msg_missing_nif', invoice=invoice)) if not_exempt_kind: if not_exempt_kind == 'S2': # inv. subj. pass. tax_detail = { 'TipoImpositivo': 0, 'BaseImponible': self.get_tax_base(tax), 'CuotaRepercutida': 0 } else: tax_detail = self.build_taxes(tax) if tax_detail: if (not detail['Sujeta'] or not detail['Sujeta'].get('NoExenta')): detail['Sujeta'].update({ 'NoExenta': { 'TipoNoExenta': not_exempt_kind, 'DesgloseIVA': { 'DetalleIVA': [tax_detail] } } }) else: detail['Sujeta']['NoExenta']['DesgloseIVA'][ 'DetalleIVA'].append(tax_detail) elif exempt_kind: if exempt_kind != 'NotSubject': baseimponible = self.get_tax_base(tax) if detail['Sujeta'].get('Exenta', {}).get( 'DetalleExenta', {}).get( 'CausaExencion', None) == exempt_kind: baseimponible += detail['Sujeta'].get('Exenta').get( 'DetalleExenta').get('BaseImponible', 0) detail['Sujeta'].update({ 'Exenta': { 'DetalleExenta': { 'CausaExencion': exempt_kind, 'BaseImponible': baseimponible, } } }) if self.location_rules(invoice): detail['NoSujeta'].update({ 'ImporteTAIReglasLocalizacion': self.location_rules( invoice) }) elif self.not_subject(invoice): detail['NoSujeta'].update({ 'ImportePorArticulos7_14_Otros': self.not_subject( invoice), }) # remove unused key for key in ('Sujeta', 'NoSujeta'): if not detail[key]: detail.pop(key) if must_detail_op: if not taxes: if self.not_subject(invoice): ret['TipoDesglose']['DesgloseTipoOperacion'].pop( 'PrestacionServicios') elif self.location_rules(invoice): ret['TipoDesglose']['DesgloseTipoOperacion'].pop('Entrega') else: if tax.tax.service: ret['TipoDesglose']['DesgloseTipoOperacion'].pop('Entrega') else: ret['TipoDesglose']['DesgloseTipoOperacion'].pop( 'PrestacionServicios') self._update_total_amount(ret, invoice) self._update_rectified_invoice(ret, invoice) return ret def _update_total_amount(self, ret, invoice): if ( ret['TipoFactura'] == 'R5' and ret['TipoDesglose']['DesgloseFactura']['Sujeta'].get('NoExenta', None) and len( ret['TipoDesglose']['DesgloseFactura']['Sujeta']['NoExenta'] ['DesgloseIVA']['DetalleIVA'] ) == 1 and ( ret['TipoDesglose']['DesgloseFactura']['Sujeta']['NoExenta'] ['DesgloseIVA']['DetalleIVA'][0]['BaseImponible'] == 0 ) ): ret['ImporteTotal'] = self.total_amount(invoice) def _update_counterpart(self, ret, invoice): if ret['TipoFactura'] not in {'F2', 'F4', 'R5'}: ret['Contraparte'] = self._build_counterpart(invoice) def _update_rectified_invoice(self, ret, invoice): if ret['TipoFactura'] in RECTIFIED_KINDS: ret['TipoRectificativa'] = self.rectified_invoice_kind(invoice) if ret['TipoRectificativa'] == 'S': ret['ImporteRectificacion'] = { 'BaseRectificada': self.rectified_base(invoice), 'CuotaRectificada': self.rectified_amount(invoice), # TODO: CuotaRecargoRectificado } class RecievedInvoiceMapper(BaseInvoiceMapper): """ Tryton Recieved Invoice to AEAT mapper """ __name__ = 'aeat.sii.recieved.invoice.mapper' specialkey_or_trascendence = attrgetter('sii_received_key') move_date = attrgetter('move.date') def _is_first_semester(self, invoice): return self.specialkey_or_trascendence(invoice) == \ SEMESTER1_RECIEVED_SPECIALKEY def _deductible_amount(self, invoice): val = Decimal(0) for tax in self.taxes(invoice): if tax.tax.deducible: val += tax.company_amount return val if not self._is_first_semester(invoice) else 0 def _move_date(self, invoice): return ( self.move_date(invoice) if not self._is_first_semester(invoice) else self.sent_date(invoice) ) def sent_date(self, invoice): # Unless overriden, the date an invoice is sent to the SII system # is assumed to be the date it is being mapped return date.today() def build_delete_request(self, invoice): return { 'PeriodoLiquidacion': self._build_period(invoice), 'IDFactura': self._build_invoice_id(invoice), } def build_submit_request(self, invoice): request = self.build_delete_request(invoice) request['FacturaRecibida'] = self.build_received_invoice(invoice) return request _build_issuer_id = BaseInvoiceMapper._build_counterpart def build_received_invoice(self, invoice): ret = { 'TipoFactura': self.invoice_kind(invoice), # TODO: FacturasAgrupadas: {IDFacturaAgrupada: [{Num, Fecha}]} # TODO: FechaOperacion 'ClaveRegimenEspecialOTrascendencia': self.specialkey_or_trascendence(invoice), # TODO: ClaveRegimenEspecialOTrascendenciaAdicional1 # TODO: ClaveRegimenEspecialOTrascendenciaAdicional2 # TODO: NumRegistroAcuerdoFacturacion 'ImporteTotal': self.total_amount(invoice), # TODO: BaseImponibleACoste 'DescripcionOperacion': self._description(invoice), 'DesgloseFactura': {}, 'Contraparte': self._build_counterpart(invoice), 'FechaRegContable': self._move_date(invoice).strftime(_DATE_FMT), 'CuotaDeducible': self._deductible_amount(invoice), # TODO: ADeducirEnPeriodoPosterior # TODO: EjercicioDeduccion # TODO: PeriodoDeduccion } _taxes = self.taxes(invoice) isp_taxes = self.isp_taxes(_taxes) _taxes = list(set(_taxes) - set(isp_taxes)) if _taxes: ret['DesgloseFactura']['DesgloseIVA'] = { 'DetalleIVA': [], } for tax in _taxes: validate_tax = self.build_taxes(invoice, tax) if validate_tax: ret['DesgloseFactura']['DesgloseIVA']['DetalleIVA'].append( validate_tax) if isp_taxes: ret['DesgloseFactura']['InversionSujetoPasivo'] = { 'DetalleIVA': [] } for tax in isp_taxes: validate_tax = self.build_taxes(invoice, tax) if validate_tax: ret['DesgloseFactura']['InversionSujetoPasivo'][ 'DetalleIVA'].append(validate_tax) self._update_rectified_invoice(ret, invoice) return ret def _update_rectified_invoice(self, ret, invoice): if ret['TipoFactura'] in RECTIFIED_KINDS: ret['TipoRectificativa'] = self.rectified_invoice_kind(invoice) # TODO: FacturasRectificadas:{IDFacturaRectificada:[{Num, Fecha}]} # TODO: ImporteRectificacion: { # BaseRectificada, CuotaRectificada, CuotaRecargoRectificado } def build_taxes(self, invoice, tax): if not tax: return {} # In case base is 0, return only the tax, not the possible IRPF. if self.tax_base(tax) == Decimal(0): ret = { 'TipoImpositivo': tools._rate_to_percent(self.tax_rate(tax)), 'BaseImponible': Decimal(0), 'CuotaSoportada': Decimal(0), } else: ret = { 'BaseImponible': self.tax_base(tax), } if self.specialkey_or_trascendence(invoice) != '02': ret['TipoImpositivo'] = tools._rate_to_percent(self.tax_rate(tax)) ret['CuotaSoportada'] = self.tax_amount(tax) if self.tax_equivalence_surcharge_rate(tax): ret['TipoRecargoEquivalencia'] = \ tools._rate_to_percent(self.tax_equivalence_surcharge_rate( tax)) if self.tax_equivalence_surcharge_amount(tax): ret['CuotaRecargoEquivalencia'] = \ self.tax_equivalence_surcharge_amount(tax) bieninversion = all(map(lambda w: w in tax.tax.name, ( 'bien', 'inversión'))) ret['BienInversion'] = 'S' if bieninversion else 'N' else: ret['PorcentCompensacionREAGYP'] = \ tools._rate_to_percent(self.tax_rate(tax)) ret['ImporteCompensacionREAGYP'] = \ (self.tax_amount(tax)) return ret def isp_taxes(self, taxes): return [tax for tax in taxes if tax.tax.isp]