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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/COMPLIANCE.md +149 -0
  3. data/LICENSE +21 -0
  4. data/README.md +404 -0
  5. data/doc/FUENTES.md +505 -0
  6. data/lib/generators/verifactu/install/install_generator.rb +61 -0
  7. data/lib/generators/verifactu/install/templates/instalar_verifactu.rb.tt +19 -0
  8. data/lib/generators/verifactu/install/templates/verifactu.rb.tt +64 -0
  9. data/lib/verifactu-rails.rb +31 -0
  10. data/lib/verifactu_rails/certificado.rb +122 -0
  11. data/lib/verifactu_rails/consulta.rb +279 -0
  12. data/lib/verifactu_rails/desglose.rb +396 -0
  13. data/lib/verifactu_rails/envio.rb +153 -0
  14. data/lib/verifactu_rails/error.rb +31 -0
  15. data/lib/verifactu_rails/formato.rb +199 -0
  16. data/lib/verifactu_rails/huella.rb +130 -0
  17. data/lib/verifactu_rails/importe.rb +80 -0
  18. data/lib/verifactu_rails/libro/autochequeo.rb +67 -0
  19. data/lib/verifactu_rails/libro/cadena.rb +110 -0
  20. data/lib/verifactu_rails/libro/migracion.rb +92 -0
  21. data/lib/verifactu_rails/libro/reconciliacion.rb +224 -0
  22. data/lib/verifactu_rails/libro/registro.rb +74 -0
  23. data/lib/verifactu_rails/libro/remesa.rb +120 -0
  24. data/lib/verifactu_rails/libro.rb +89 -0
  25. data/lib/verifactu_rails/qr.rb +56 -0
  26. data/lib/verifactu_rails/railtie.rb +30 -0
  27. data/lib/verifactu_rails/registro.rb +833 -0
  28. data/lib/verifactu_rails/respuesta.rb +154 -0
  29. data/lib/verifactu_rails/schemas/ConsultaLR.xsd +54 -0
  30. data/lib/verifactu_rails/schemas/EventosSIF.xsd +823 -0
  31. data/lib/verifactu_rails/schemas/PROCEDENCIA.md +64 -0
  32. data/lib/verifactu_rails/schemas/RespuestaConsultaLR.xsd +201 -0
  33. data/lib/verifactu_rails/schemas/RespuestaSuministro.xsd +139 -0
  34. data/lib/verifactu_rails/schemas/RespuestaValRegistNoVeriFactu.xsd +103 -0
  35. data/lib/verifactu_rails/schemas/SuministroInformacion.xsd +1390 -0
  36. data/lib/verifactu_rails/schemas/SuministroLR.xsd +25 -0
  37. data/lib/verifactu_rails/schemas/catalog.xml +5 -0
  38. data/lib/verifactu_rails/schemas/xmldsig-core-schema.xsd +318 -0
  39. data/lib/verifactu_rails/sistema_informatico.rb +88 -0
  40. data/lib/verifactu_rails/transporte.rb +127 -0
  41. data/lib/verifactu_rails/version.rb +5 -0
  42. 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