verifactu-rails 0.1.0.pre
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/COMPLIANCE.md +149 -0
- data/LICENSE +21 -0
- data/README.md +404 -0
- data/doc/FUENTES.md +505 -0
- data/lib/generators/verifactu/install/install_generator.rb +61 -0
- data/lib/generators/verifactu/install/templates/instalar_verifactu.rb.tt +19 -0
- data/lib/generators/verifactu/install/templates/verifactu.rb.tt +64 -0
- data/lib/verifactu-rails.rb +31 -0
- data/lib/verifactu_rails/certificado.rb +122 -0
- data/lib/verifactu_rails/consulta.rb +279 -0
- data/lib/verifactu_rails/desglose.rb +396 -0
- data/lib/verifactu_rails/envio.rb +153 -0
- data/lib/verifactu_rails/error.rb +31 -0
- data/lib/verifactu_rails/formato.rb +199 -0
- data/lib/verifactu_rails/huella.rb +130 -0
- data/lib/verifactu_rails/importe.rb +80 -0
- data/lib/verifactu_rails/libro/autochequeo.rb +67 -0
- data/lib/verifactu_rails/libro/cadena.rb +110 -0
- data/lib/verifactu_rails/libro/migracion.rb +92 -0
- data/lib/verifactu_rails/libro/reconciliacion.rb +224 -0
- data/lib/verifactu_rails/libro/registro.rb +74 -0
- data/lib/verifactu_rails/libro/remesa.rb +120 -0
- data/lib/verifactu_rails/libro.rb +89 -0
- data/lib/verifactu_rails/qr.rb +56 -0
- data/lib/verifactu_rails/railtie.rb +30 -0
- data/lib/verifactu_rails/registro.rb +833 -0
- data/lib/verifactu_rails/respuesta.rb +154 -0
- data/lib/verifactu_rails/schemas/ConsultaLR.xsd +54 -0
- data/lib/verifactu_rails/schemas/EventosSIF.xsd +823 -0
- data/lib/verifactu_rails/schemas/PROCEDENCIA.md +64 -0
- data/lib/verifactu_rails/schemas/RespuestaConsultaLR.xsd +201 -0
- data/lib/verifactu_rails/schemas/RespuestaSuministro.xsd +139 -0
- data/lib/verifactu_rails/schemas/RespuestaValRegistNoVeriFactu.xsd +103 -0
- data/lib/verifactu_rails/schemas/SuministroInformacion.xsd +1390 -0
- data/lib/verifactu_rails/schemas/SuministroLR.xsd +25 -0
- data/lib/verifactu_rails/schemas/catalog.xml +5 -0
- data/lib/verifactu_rails/schemas/xmldsig-core-schema.xsd +318 -0
- data/lib/verifactu_rails/sistema_informatico.rb +88 -0
- data/lib/verifactu_rails/transporte.rb +127 -0
- data/lib/verifactu_rails/version.rb +5 -0
- metadata +124 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Punto de entrada de la gema. El nombre del fichero lleva guion (verifactu-rails)
|
|
4
|
+
# para coincidir con el nombre de la gema; los internos van en verifactu_rails/.
|
|
5
|
+
require_relative 'verifactu_rails/version'
|
|
6
|
+
require_relative 'verifactu_rails/error'
|
|
7
|
+
require_relative 'verifactu_rails/formato'
|
|
8
|
+
require_relative 'verifactu_rails/importe'
|
|
9
|
+
require_relative 'verifactu_rails/huella'
|
|
10
|
+
require_relative 'verifactu_rails/desglose'
|
|
11
|
+
require_relative 'verifactu_rails/sistema_informatico'
|
|
12
|
+
require_relative 'verifactu_rails/registro'
|
|
13
|
+
require_relative 'verifactu_rails/envio'
|
|
14
|
+
require_relative 'verifactu_rails/respuesta'
|
|
15
|
+
require_relative 'verifactu_rails/consulta'
|
|
16
|
+
require_relative 'verifactu_rails/qr'
|
|
17
|
+
require_relative 'verifactu_rails/certificado'
|
|
18
|
+
require_relative 'verifactu_rails/transporte'
|
|
19
|
+
|
|
20
|
+
# Integración con VERI*FACTU (AEAT) para aplicaciones Ruby y Rails.
|
|
21
|
+
#
|
|
22
|
+
# ALCANCE: únicamente modalidad VERI*FACTU (remisión continua a la AEAT). No se
|
|
23
|
+
# implementa el modo NO VERI*FACTU, que exigiría firma XAdES de cada registro y
|
|
24
|
+
# registro de eventos.
|
|
25
|
+
module VerifactuRails
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Solo si hay Rails delante. El núcleo funciona suelto y así debe seguir: esto
|
|
29
|
+
# no lo carga, únicamente engancha la capa Libro a ActiveRecord y deja que Rails
|
|
30
|
+
# descubra `rails g verifactu:install`.
|
|
31
|
+
require_relative 'verifactu_rails/railtie' if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require_relative 'error'
|
|
5
|
+
|
|
6
|
+
module VerifactuRails
|
|
7
|
+
class CertificadoError < StandardError
|
|
8
|
+
include Error
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Envuelve el certificado del obligado tributario usado para la autenticación
|
|
12
|
+
# mutua TLS contra la AEAT.
|
|
13
|
+
#
|
|
14
|
+
# En modalidad VERI*FACTU el certificado NO firma los registros: solo autentica
|
|
15
|
+
# el canal. Aun así es la credencial más sensible del sistema, así que esta clase
|
|
16
|
+
# deliberadamente NO sabe leer ficheros ni variables de entorno: recibe los bytes
|
|
17
|
+
# ya cargados. La política de almacenamiento es de la capa de integración.
|
|
18
|
+
class Certificado
|
|
19
|
+
AVISO_CADUCIDAD_DIAS = 30
|
|
20
|
+
|
|
21
|
+
attr_reader :certificado, :clave, :cadena
|
|
22
|
+
|
|
23
|
+
def initialize(certificado:, clave:, cadena: nil)
|
|
24
|
+
@certificado = certificado
|
|
25
|
+
@clave = clave
|
|
26
|
+
@cadena = Array(cadena)
|
|
27
|
+
validar!
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Construye desde los bytes de un .p12/.pfx (lo que el usuario descarga de la
|
|
31
|
+
# FNMT). Evitamos obligarle a partirlo en PEM con openssl a mano.
|
|
32
|
+
def self.desde_pkcs12(datos, password)
|
|
33
|
+
raise CertificadoError, 'Datos PKCS12 vacíos' if datos.nil? || datos.empty?
|
|
34
|
+
|
|
35
|
+
p12 = OpenSSL::PKCS12.new(datos, password.to_s)
|
|
36
|
+
new(certificado: p12.certificate, clave: p12.key, cadena: p12.ca_certs)
|
|
37
|
+
rescue OpenSSL::PKCS12::PKCS12Error => e
|
|
38
|
+
raise CertificadoError,
|
|
39
|
+
'No se pudo abrir el PKCS12: contraseña incorrecta o fichero corrupto ' \
|
|
40
|
+
"(#{e.message})"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def caducado?
|
|
44
|
+
Time.now > certificado.not_after
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def dias_para_caducar
|
|
48
|
+
((certificado.not_after - Time.now) / 86_400).floor
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Los .p12 caducan siempre en mal momento. Que el sistema avise solo.
|
|
52
|
+
def caduca_pronto?(umbral = AVISO_CADUCIDAD_DIAS)
|
|
53
|
+
dias_para_caducar <= umbral
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def sujeto
|
|
57
|
+
certificado.subject.to_a.each_with_object({}) { |(k, v, _), h| h[k] = v }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Un certificado de sello de entidad se dirige a un endpoint DISTINTO de la
|
|
61
|
+
# AEAT. Heurística sobre el sujeto; el integrador puede forzarlo en config.
|
|
62
|
+
def sello?
|
|
63
|
+
valores = sujeto.values.join(' ').downcase
|
|
64
|
+
valores.include?('sello') || valores.include?('seal')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# NIF del titular, tal como lo graba la FNMT en el sujeto.
|
|
68
|
+
#
|
|
69
|
+
# Sirve para una comprobación que ahorra viajes: la AEAT exige que el titular
|
|
70
|
+
# del certificado coincida con el ObligadoEmision, y si no coinciden responde
|
|
71
|
+
# un 4104 "no está identificado" que parece decir que el NIF no existe.
|
|
72
|
+
#
|
|
73
|
+
# Es una heurística sobre formatos observados (serialNumber "IDCES-xxxxxxxxX"
|
|
74
|
+
# o el NIF suelto, y CN terminado en "- xxxxxxxxX"), no un campo normalizado,
|
|
75
|
+
# así que devuelve nil si no lo reconoce en vez de inventarse nada.
|
|
76
|
+
PATRON_NIF = /\b([A-Z]?\d{7,8}[A-Z0-9])\b/
|
|
77
|
+
|
|
78
|
+
def nif
|
|
79
|
+
candidatos = [sujeto['serialNumber'], sujeto['CN']].compact
|
|
80
|
+
candidatos.each do |valor|
|
|
81
|
+
m = valor.upcase.sub(/\AIDCES-/, '').match(PATRON_NIF)
|
|
82
|
+
return m[1] if m && m[1].length == 9
|
|
83
|
+
end
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Nombre o razón social del titular, sin el NIF que la FNMT le pega al final
|
|
88
|
+
# del CN ("GARCIA LOPEZ ANA - 89890001K").
|
|
89
|
+
#
|
|
90
|
+
# Importa porque la AEAT identifica al obligado por el PAR NIF + NombreRazon:
|
|
91
|
+
# con el NIF correcto y un nombre que no cuadre responde 4104, y el mensaje
|
|
92
|
+
# habla solo del NIF aunque en el detalle devuelva los dos campos.
|
|
93
|
+
def titular
|
|
94
|
+
cn = sujeto['CN']
|
|
95
|
+
return nil if cn.nil?
|
|
96
|
+
|
|
97
|
+
cn.sub(/\s*-\s*#{Regexp.escape(nif.to_s)}\s*\z/, '').strip
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def resumen
|
|
101
|
+
{ titular: sujeto['CN'], organizacion: sujeto['O'],
|
|
102
|
+
caduca: certificado.not_after, dias_restantes: dias_para_caducar,
|
|
103
|
+
sello: sello? }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def validar!
|
|
109
|
+
unless certificado.is_a?(OpenSSL::X509::Certificate)
|
|
110
|
+
raise CertificadoError, 'Certificado X509 no válido'
|
|
111
|
+
end
|
|
112
|
+
raise CertificadoError, 'Clave privada ausente en el PKCS12' if clave.nil?
|
|
113
|
+
unless certificado.check_private_key(clave)
|
|
114
|
+
raise CertificadoError, 'La clave privada no corresponde al certificado'
|
|
115
|
+
end
|
|
116
|
+
if caducado?
|
|
117
|
+
raise CertificadoError,
|
|
118
|
+
"Certificado caducado el #{certificado.not_after.strftime('%d-%m-%Y')}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
require_relative 'formato'
|
|
5
|
+
require_relative 'registro'
|
|
6
|
+
require_relative 'error'
|
|
7
|
+
|
|
8
|
+
module VerifactuRails
|
|
9
|
+
NS_LRC = 'https://www2.agenciatributaria.gob.es/static_files/common/internet/' \
|
|
10
|
+
'dep/aplicaciones/es/aeat/tike/cont/ws/ConsultaLR.xsd'
|
|
11
|
+
|
|
12
|
+
# Consulta de los registros ya anotados (ConsultaLR.xsd).
|
|
13
|
+
#
|
|
14
|
+
# Por qué hace falta un servicio aparte: la respuesta al ENVÍO dice si la AEAT
|
|
15
|
+
# aceptó el registro, no qué queda almacenado, y los estados NO son los mismos.
|
|
16
|
+
# RespuestaSuministro solo conoce Correcto, AceptadoConErrores e Incorrecto;
|
|
17
|
+
# la consulta añade "Anulado", que el canal de envío no sabe expresar. Es decir:
|
|
18
|
+
# que una anulación se anote correctamente y que la factura quede anulada son
|
|
19
|
+
# dos hechos distintos, y el segundo solo se observa desde aquí.
|
|
20
|
+
#
|
|
21
|
+
# Consultar es de solo lectura: no crea registros ni toca la cadena.
|
|
22
|
+
#
|
|
23
|
+
# Va al MISMO endpoint que el envío (VerifactuSOAP): lo que cambia es el
|
|
24
|
+
# elemento del Body, no la URL, así que `Transporte` sirve tal cual.
|
|
25
|
+
class Consulta
|
|
26
|
+
# TipoPeriodoType es mensual: 01-12. No hay periodos trimestrales.
|
|
27
|
+
PERIODOS = %w[01 02 03 04 05 06 07 08 09 10 11 12].freeze
|
|
28
|
+
PATRON_EJERCICIO = /\A\d{4}\z/ # sf:YearType
|
|
29
|
+
|
|
30
|
+
attr_reader :nif_obligado, :nombre_obligado, :ejercicio, :periodo,
|
|
31
|
+
:num_serie, :fecha_expedicion, :desde, :hasta,
|
|
32
|
+
:sistema_informatico, :clave_paginacion
|
|
33
|
+
|
|
34
|
+
# @param periodo [String, Integer] mes de imputación; 8 y '08' valen igual.
|
|
35
|
+
# @param sistema_informatico [SistemaInformatico, nil] filtra por SIF. Con la
|
|
36
|
+
# arquitectura de "SIF virtuales" (un NumeroInstalacion por fuente de
|
|
37
|
+
# facturación) esto es lo que aísla la cadena de una fuente concreta.
|
|
38
|
+
# @param clave_paginacion [IdFactura, nil] la que devolvió la página anterior.
|
|
39
|
+
def initialize(nif_obligado:, nombre_obligado:, ejercicio:, periodo:,
|
|
40
|
+
num_serie: nil, fecha_expedicion: nil, desde: nil, hasta: nil,
|
|
41
|
+
sistema_informatico: nil, clave_paginacion: nil)
|
|
42
|
+
@nif_obligado = Formato.nif(nif_obligado, 'NIF del obligado')
|
|
43
|
+
@nombre_obligado = Formato.limitar(nombre_obligado, 'NombreRazon del obligado', 120)
|
|
44
|
+
@ejercicio = validar_ejercicio(ejercicio)
|
|
45
|
+
@periodo = Formato.enumerado(formatear_periodo(periodo), 'Periodo', PERIODOS)
|
|
46
|
+
@num_serie = num_serie && Formato.num_serie(num_serie)
|
|
47
|
+
@fecha_expedicion = fecha_expedicion && Formato.fecha(fecha_expedicion)
|
|
48
|
+
@desde = desde && Formato.fecha(desde)
|
|
49
|
+
@hasta = hasta && Formato.fecha(hasta)
|
|
50
|
+
@sistema_informatico = sistema_informatico &&
|
|
51
|
+
Formato.objeto(sistema_informatico, 'sistema_informatico',
|
|
52
|
+
SistemaInformatico)
|
|
53
|
+
@clave_paginacion = clave_paginacion &&
|
|
54
|
+
Formato.objeto(clave_paginacion, 'clave_paginacion', IdFactura)
|
|
55
|
+
|
|
56
|
+
validar_fechas!
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def to_xml
|
|
60
|
+
constructor = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
|
|
61
|
+
xml['con'].ConsultaFactuSistemaFacturacion('xmlns:con' => NS_LRC, 'xmlns:sum1' => NS_SF) do
|
|
62
|
+
# Cabecera se declara en ConsultaLR, pero su CONTENIDO es de
|
|
63
|
+
# SuministroInformacion: por eso el prefijo cambia al entrar. Con
|
|
64
|
+
# elementFormDefault="qualified" en ambos esquemas, equivocarse aquí
|
|
65
|
+
# invalida el documento aunque el árbol "se vea" bien.
|
|
66
|
+
xml['con'].Cabecera do
|
|
67
|
+
xml['sum1'].IDVersion IDVERSION
|
|
68
|
+
xml['sum1'].ObligadoEmision do
|
|
69
|
+
xml['sum1'].NombreRazon nombre_obligado
|
|
70
|
+
xml['sum1'].NIF nif_obligado
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
xml['con'].FiltroConsulta do
|
|
74
|
+
xml['con'].PeriodoImputacion do
|
|
75
|
+
xml['sum1'].Ejercicio ejercicio
|
|
76
|
+
xml['sum1'].Periodo periodo
|
|
77
|
+
end
|
|
78
|
+
xml['con'].NumSerieFactura num_serie if num_serie
|
|
79
|
+
construir_fechas(xml)
|
|
80
|
+
if sistema_informatico
|
|
81
|
+
xml['con'].SistemaInformatico do
|
|
82
|
+
sistema_informatico.a_pares.each { |campo, valor| xml['sum1'].send(campo, valor) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
if clave_paginacion
|
|
86
|
+
xml['con'].ClavePaginacion do
|
|
87
|
+
clave_paginacion.a_pares.each { |campo, valor| xml['sum1'].send(campo, valor) }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
constructor.to_xml
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# FechaExpedicionConsultaType es un <choice>: o fecha suelta o rango. Sin esta
|
|
99
|
+
# guarda se emitían los dos y el documento salía inválido solo al validar.
|
|
100
|
+
def validar_fechas!
|
|
101
|
+
return unless fecha_expedicion && (desde || hasta)
|
|
102
|
+
|
|
103
|
+
raise ValidacionError,
|
|
104
|
+
'fecha_expedicion: y el rango desde:/hasta: se excluyen: el esquema ' \
|
|
105
|
+
'admite una fecha concreta O un rango, no ambos'
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def construir_fechas(xml)
|
|
109
|
+
return unless fecha_expedicion || desde || hasta
|
|
110
|
+
|
|
111
|
+
xml['con'].FechaExpedicionFactura do
|
|
112
|
+
if fecha_expedicion
|
|
113
|
+
xml['sum1'].FechaExpedicionFactura fecha_expedicion
|
|
114
|
+
else
|
|
115
|
+
xml['sum1'].RangoFechaExpedicion do
|
|
116
|
+
xml['sum1'].Desde desde if desde
|
|
117
|
+
xml['sum1'].Hasta hasta if hasta
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validar_ejercicio(valor)
|
|
124
|
+
cadena = valor.to_s
|
|
125
|
+
return cadena if cadena.match?(PATRON_EJERCICIO)
|
|
126
|
+
|
|
127
|
+
raise ValidacionError, "Ejercicio debe ser un año de cuatro dígitos: #{valor.inspect}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Se admite el entero por comodidad (periodo: 8), pero el XSD exige dos
|
|
131
|
+
# posiciones: sin rellenar el cero, "8" se rechazaría.
|
|
132
|
+
def formatear_periodo(valor)
|
|
133
|
+
return format('%<mes>02d', mes: valor) if valor.is_a?(Integer)
|
|
134
|
+
|
|
135
|
+
valor.to_s
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Respuesta a una consulta (RespuestaConsultaLR.xsd).
|
|
140
|
+
#
|
|
141
|
+
# Igual que Respuesta: solo lee. Lo que llega de la AEAT es la verdad, y una
|
|
142
|
+
# respuesta que no encaje con lo que esperamos no debe impedir leer el resto.
|
|
143
|
+
#
|
|
144
|
+
# LO QUE DEVUELVE ES UNA FOTO, NO UN LIBRO (comprobado contra preproducción):
|
|
145
|
+
# una fila por FACTURA con su estado ACTUAL, no un histórico de registros de
|
|
146
|
+
# facturación. Si una factura se subsanó, se devuelve la subsanación y el alta
|
|
147
|
+
# original desaparece; si se anuló, se devuelve la anulación con estado
|
|
148
|
+
# "Anulado". Consecuencia práctica: la cadena NO se puede reconstruir entera
|
|
149
|
+
# desde aquí, porque los eslabones sustituidos ya no salen y los registros que
|
|
150
|
+
# encadenaban tras ellos parecen huérfanos aunque la cadena esté intacta.
|
|
151
|
+
class RespuestaConsulta
|
|
152
|
+
NS = 'https://www2.agenciatributaria.gob.es/static_files/common/internet/' \
|
|
153
|
+
'dep/aplicaciones/es/aeat/tike/cont/ws/RespuestaConsultaLR.xsd'
|
|
154
|
+
NS_SF_RESP = 'https://www2.agenciatributaria.gob.es/static_files/common/internet/' \
|
|
155
|
+
'dep/aplicaciones/es/aeat/tike/cont/ws/SuministroInformacion.xsd'
|
|
156
|
+
NS_SOAP = 'http://schemas.xmlsoap.org/soap/envelope/'
|
|
157
|
+
|
|
158
|
+
# OJO: NO son los mismos estados que en RespuestaSuministro. Aquí no existe
|
|
159
|
+
# "Incorrecto" -un registro rechazado no se almacena, así que no se puede
|
|
160
|
+
# consultar- y sí existe "Anulado", que es el estado que da sentido a todo
|
|
161
|
+
# este servicio.
|
|
162
|
+
ESTADOS = %w[Correcto AceptadoConErrores Anulado].freeze
|
|
163
|
+
|
|
164
|
+
# Un registro tal y como la AEAT lo tiene guardado.
|
|
165
|
+
Anotado = Struct.new(:id_emisor, :num_serie, :fecha_expedicion, :estado,
|
|
166
|
+
:timestamp_modificacion, :codigo_error, :descripcion_error,
|
|
167
|
+
:tipo_factura, :subsanacion, :huella, :primer_registro,
|
|
168
|
+
:huella_anterior, :num_serie_anterior, :cuota_total,
|
|
169
|
+
:importe_total, :fecha_hora_gen, :numero_instalacion,
|
|
170
|
+
keyword_init: true) do
|
|
171
|
+
def correcto? = estado == 'Correcto'
|
|
172
|
+
def aceptado_con_errores? = estado == 'AceptadoConErrores'
|
|
173
|
+
|
|
174
|
+
# La factura fue anulada por un registro de anulación posterior. Este
|
|
175
|
+
# estado NO existe en la respuesta al envío.
|
|
176
|
+
def anulado? = estado == 'Anulado'
|
|
177
|
+
|
|
178
|
+
def subsanacion? = subsanacion == 'S'
|
|
179
|
+
def primer_registro? = primer_registro == 'S'
|
|
180
|
+
|
|
181
|
+
def to_s
|
|
182
|
+
base = "#{num_serie}: #{estado}"
|
|
183
|
+
base += ' (subsanación)' if subsanacion?
|
|
184
|
+
codigo_error ? "#{base} [#{codigo_error}] #{descripcion_error}" : base
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
attr_reader :xml, :ejercicio, :periodo, :indicador_paginacion,
|
|
189
|
+
:resultado, :registros, :clave_paginacion
|
|
190
|
+
|
|
191
|
+
def initialize(xml)
|
|
192
|
+
@xml = xml
|
|
193
|
+
doc = Nokogiri::XML(xml)
|
|
194
|
+
raiz = doc.at_xpath('//c:RespuestaConsultaFactuSistemaFacturacion', 'c' => NS)
|
|
195
|
+
|
|
196
|
+
if raiz.nil?
|
|
197
|
+
raise RespuestaError,
|
|
198
|
+
'La respuesta no contiene RespuestaConsultaFactuSistemaFacturacion. ' \
|
|
199
|
+
"#{resumen_de_fallo(doc)}"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
@ejercicio = texto(raiz, 'c:PeriodoImputacion/c:Ejercicio')
|
|
203
|
+
@periodo = texto(raiz, 'c:PeriodoImputacion/c:Periodo')
|
|
204
|
+
@indicador_paginacion = texto(raiz, 'c:IndicadorPaginacion')
|
|
205
|
+
@resultado = texto(raiz, 'c:ResultadoConsulta')
|
|
206
|
+
@registros = raiz.xpath('c:RegistroRespuestaConsultaFactuSistemaFacturacion', 'c' => NS)
|
|
207
|
+
.map { |n| leer_registro(n) }
|
|
208
|
+
@clave_paginacion = leer_clave(raiz.at_xpath('c:ClavePaginacion', 'c' => NS))
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def con_datos? = resultado == 'ConDatos'
|
|
212
|
+
def sin_datos? = resultado == 'SinDatos'
|
|
213
|
+
|
|
214
|
+
# Quedan más páginas. La siguiente consulta se hace repitiendo el mismo
|
|
215
|
+
# filtro con clave_paginacion:, no cambiando el periodo.
|
|
216
|
+
def hay_mas_paginas? = indicador_paginacion == 'S'
|
|
217
|
+
|
|
218
|
+
def anulados = registros.select(&:anulado?)
|
|
219
|
+
def a_subsanar = registros.select(&:aceptado_con_errores?)
|
|
220
|
+
def subsanaciones = registros.select(&:subsanacion?)
|
|
221
|
+
|
|
222
|
+
def to_s
|
|
223
|
+
"#{resultado} (#{registros.size} registros: #{anulados.size} anulados, " \
|
|
224
|
+
"#{a_subsanar.size} con errores)#{hay_mas_paginas? ? ', hay más páginas' : ''}"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
def leer_registro(nodo)
|
|
230
|
+
datos = nodo.at_xpath('c:DatosRegistroFacturacion', 'c' => NS)
|
|
231
|
+
estado = nodo.at_xpath('c:EstadoRegistro', 'c' => NS)
|
|
232
|
+
anterior = datos&.at_xpath('c:Encadenamiento/c:RegistroAnterior', 'c' => NS)
|
|
233
|
+
|
|
234
|
+
Anotado.new(
|
|
235
|
+
id_emisor: texto(nodo, 'c:IDFactura/sf:IDEmisorFactura'),
|
|
236
|
+
num_serie: texto(nodo, 'c:IDFactura/sf:NumSerieFactura'),
|
|
237
|
+
fecha_expedicion: texto(nodo, 'c:IDFactura/sf:FechaExpedicionFactura'),
|
|
238
|
+
estado: estado && texto(estado, 'c:EstadoRegistro'),
|
|
239
|
+
timestamp_modificacion: estado && texto(estado, 'c:TimestampUltimaModificacion'),
|
|
240
|
+
codigo_error: estado && texto(estado, 'c:CodigoErrorRegistro'),
|
|
241
|
+
descripcion_error: estado && texto(estado, 'c:DescripcionErrorRegistro'),
|
|
242
|
+
tipo_factura: datos && texto(datos, 'c:TipoFactura'),
|
|
243
|
+
subsanacion: datos && texto(datos, 'c:Subsanacion'),
|
|
244
|
+
huella: datos && texto(datos, 'c:Huella'),
|
|
245
|
+
primer_registro: datos && texto(datos, 'c:Encadenamiento/c:PrimerRegistro'),
|
|
246
|
+
huella_anterior: anterior && texto(anterior, 'sf:Huella'),
|
|
247
|
+
num_serie_anterior: anterior && texto(anterior, 'sf:NumSerieFactura'),
|
|
248
|
+
cuota_total: datos && texto(datos, 'c:CuotaTotal'),
|
|
249
|
+
importe_total: datos && texto(datos, 'c:ImporteTotal'),
|
|
250
|
+
fecha_hora_gen: datos && texto(datos, 'c:FechaHoraHusoGenRegistro'),
|
|
251
|
+
numero_instalacion: datos && texto(datos, 'c:SistemaInformatico/sf:NumeroInstalacion')
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Se devuelve como IdFactura para poder pasarla tal cual a la siguiente
|
|
256
|
+
# Consulta, que es lo único que se hace con ella.
|
|
257
|
+
def leer_clave(nodo)
|
|
258
|
+
return nil if nodo.nil?
|
|
259
|
+
|
|
260
|
+
IdFactura.new(
|
|
261
|
+
id_emisor: texto(nodo, 'sf:IDEmisorFactura'),
|
|
262
|
+
num_serie: texto(nodo, 'sf:NumSerieFactura'),
|
|
263
|
+
fecha_expedicion: texto(nodo, 'sf:FechaExpedicionFactura')
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def texto(nodo, ruta)
|
|
268
|
+
nodo.at_xpath(ruta, 'c' => NS, 'sf' => NS_SF_RESP)&.text
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def resumen_de_fallo(doc)
|
|
272
|
+
fault = doc.at_xpath('//soap:Fault', 'soap' => NS_SOAP)
|
|
273
|
+
return "Cuerpo recibido: #{xml.to_s[0, 300]}" if fault.nil?
|
|
274
|
+
|
|
275
|
+
"La AEAT devolvió un SOAP Fault #{fault.at_xpath('faultcode')&.text}: " \
|
|
276
|
+
"#{fault.at_xpath('faultstring')&.text}"
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|