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,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VerifactuRails
|
|
4
|
+
module Libro
|
|
5
|
+
# Contraste entre lo que dice el libro registro y lo que la AEAT tiene
|
|
6
|
+
# realmente anotado.
|
|
7
|
+
#
|
|
8
|
+
# SOLO LEE. No corrige nada, ni siquiera lo que parece obvio, y es una
|
|
9
|
+
# decisión, no una versión reducida: lo que esto detecta es divergencia entre
|
|
10
|
+
# el sistema de registro y la AEAT, y un job que reescribiera el estado local
|
|
11
|
+
# para "cuadrar" taparía justo lo que se le ha pedido revelar. Además no hace
|
|
12
|
+
# falta: Remesa ya reintenta sola lo que quedó en 'enviando', porque un
|
|
13
|
+
# duplicado cuenta como éxito.
|
|
14
|
+
#
|
|
15
|
+
# QUÉ PUEDE Y QUÉ NO PUEDE COMPROBAR. La consulta devuelve una FOTO POR
|
|
16
|
+
# FACTURA, no el histórico de registros (comprobado contra preproducción: 6
|
|
17
|
+
# registros sobre 4 facturas devolvieron 4 filas). Una subsanación sustituye
|
|
18
|
+
# al alta original y la anulación sustituye a lo anulado, así que los
|
|
19
|
+
# eslabones intermedios ya no se devuelven. Consecuencias directas:
|
|
20
|
+
#
|
|
21
|
+
# - Se reconcilia el ESTADO ACTUAL de cada factura. Es lo que esto hace.
|
|
22
|
+
# - NO se puede auditar el encadenamiento contra la AEAT. Los registros
|
|
23
|
+
# sustituidos faltan, y los que encadenaban tras ellos parecen huérfanos
|
|
24
|
+
# aunque la cadena esté intacta. Detectar una bifurcación sigue siendo
|
|
25
|
+
# cosa del índice único local, no de aquí.
|
|
26
|
+
#
|
|
27
|
+
# Por eso se compara contra el registro VIGENTE de cada factura -el último
|
|
28
|
+
# anotado con ese IDFactura-, que es el que la AEAT debería estar
|
|
29
|
+
# devolviendo.
|
|
30
|
+
class Reconciliacion
|
|
31
|
+
# La AEAT avisa de que preproducción es para pruebas puntuales, no para
|
|
32
|
+
# carga. Un tope explícito evita que un fallo de paginación se convierta en
|
|
33
|
+
# una tromba de peticiones; que se ha alcanzado se DICE (Informe#truncado?)
|
|
34
|
+
# en vez de devolver medio informe con cara de completo.
|
|
35
|
+
MAXIMO_PAGINAS = 100
|
|
36
|
+
|
|
37
|
+
# Los importes no se comparan: la AEAT los devuelve sin ceros a la derecha
|
|
38
|
+
# ('121' por '121.00'), así que un cotejo textual daría falsos positivos a
|
|
39
|
+
# mansalva. No se pierde nada, porque los importes entran en la huella y la
|
|
40
|
+
# huella sí se compara.
|
|
41
|
+
Divergencia = Struct.new(:tipo, :id_emisor, :num_serie, :fecha_expedicion,
|
|
42
|
+
:local, :remoto, :detalle, keyword_init: true) do
|
|
43
|
+
def to_s = "#{num_serie}: #{tipo} — #{detalle}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Informe = Struct.new(:ejercicio, :periodo, :facturas_locales, :filas_aeat,
|
|
47
|
+
:ajenas, :divergencias, :truncado, keyword_init: true) do
|
|
48
|
+
def cuadra? = divergencias.empty?
|
|
49
|
+
def truncado? = truncado == true
|
|
50
|
+
|
|
51
|
+
def to_s
|
|
52
|
+
base = "#{ejercicio}-#{periodo}: #{facturas_locales} facturas locales, " \
|
|
53
|
+
"#{filas_aeat} filas de la AEAT, #{divergencias.size} divergencias"
|
|
54
|
+
base += ", #{ajenas} de otra instalación" if ajenas.positive?
|
|
55
|
+
base += ' (INFORME TRUNCADO: se alcanzó el tope de páginas)' if truncado?
|
|
56
|
+
base
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def initialize(cadena, transporte:)
|
|
61
|
+
@cadena = cadena
|
|
62
|
+
@transporte = transporte
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param periodo [String, Integer] mes de imputación (8 y '08' valen igual).
|
|
66
|
+
# @return [Informe]
|
|
67
|
+
def revisar(ejercicio:, periodo:)
|
|
68
|
+
periodo = format('%<mes>02d', mes: periodo.to_i)
|
|
69
|
+
remotas, truncado = descargar(ejercicio: ejercicio, periodo: periodo)
|
|
70
|
+
nuestras, ajenas = remotas.partition { |fila| de_esta_instalacion?(fila) }
|
|
71
|
+
pendientes = nuestras.index_by { |fila| clave(fila) }
|
|
72
|
+
|
|
73
|
+
divergencias = vigentes(ejercicio: ejercicio, periodo: periodo).flat_map do |id, local|
|
|
74
|
+
comparar(local, pendientes.delete(id))
|
|
75
|
+
end
|
|
76
|
+
# Lo que sobra está en la AEAT y no en el libro. Es el caso grave: o
|
|
77
|
+
# factura otro sistema para este obligado, o se reutilizó un número de
|
|
78
|
+
# instalación que ya tenía historia.
|
|
79
|
+
divergencias += pendientes.each_value.map { |fila| solo_en_aeat(fila) }
|
|
80
|
+
|
|
81
|
+
Informe.new(ejercicio: ejercicio.to_s, periodo: periodo,
|
|
82
|
+
facturas_locales: vigentes(ejercicio: ejercicio, periodo: periodo).size,
|
|
83
|
+
filas_aeat: nuestras.size, ajenas: ajenas.size,
|
|
84
|
+
divergencias: divergencias, truncado: truncado)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
attr_reader :cadena, :transporte
|
|
90
|
+
|
|
91
|
+
# El registro VIGENTE de cada factura: el último anotado con ese IDFactura.
|
|
92
|
+
# El índice (id_emisor, num_serie, fecha_expedicion) NO es único a
|
|
93
|
+
# propósito -una subsanación reutiliza el mismo IDFactura-, así que aquí
|
|
94
|
+
# puede haber varias filas por factura y solo la última cuenta.
|
|
95
|
+
#
|
|
96
|
+
# Se filtra por mes de expedición asumiendo que la AEAT imputa por ella. Es
|
|
97
|
+
# la lectura natural y la que usó la campaña contra preproducción, pero es
|
|
98
|
+
# una suposición: si algún día no cuadra, mira aquí primero.
|
|
99
|
+
def vigentes(ejercicio:, periodo:)
|
|
100
|
+
@vigentes ||= {}
|
|
101
|
+
@vigentes[[ejercicio.to_s, periodo]] ||=
|
|
102
|
+
cadena.registros
|
|
103
|
+
.where('fecha_expedicion LIKE ?', "%-#{periodo}-#{ejercicio}")
|
|
104
|
+
.order(:id)
|
|
105
|
+
.each_with_object({}) { |fila, h| h[clave(fila)] = fila }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def clave(fila)
|
|
109
|
+
[fila.id_emisor, fila.num_serie, fila.fecha_expedicion]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# La fila es de esta cadena si la AEAT la atribuye a nuestra instalación.
|
|
113
|
+
#
|
|
114
|
+
# Se filtra AQUÍ, en cliente, además de mandar el SistemaInformatico en el
|
|
115
|
+
# filtro de la consulta. Todo apunta a que el servidor aplica ese cotejo (6
|
|
116
|
+
# facturas del mismo NIF y periodo en dos instalaciones, la consulta
|
|
117
|
+
# devolvió solo las 2 de la consultada), pero no está cerrado: falta
|
|
118
|
+
# verificar que las otras 4 siguieran almacenadas. Ver doc/FUENTES.md.
|
|
119
|
+
#
|
|
120
|
+
# Sea o no redundante, esto se queda: es barato, y hace que el informe siga
|
|
121
|
+
# siendo correcto tanto si la premisa falla como si algún día cambia el
|
|
122
|
+
# cotejo del servidor, en vez de llenarse de :solo_en_aeat por facturas de
|
|
123
|
+
# otras tiendas.
|
|
124
|
+
#
|
|
125
|
+
# Una fila sin NumeroInstalacion se da por nuestra: preferimos revisarla de
|
|
126
|
+
# más a dejar pasar una factura ajena sin mirar.
|
|
127
|
+
def de_esta_instalacion?(fila)
|
|
128
|
+
fila.numero_instalacion.nil? ||
|
|
129
|
+
fila.numero_instalacion == cadena.numero_instalacion
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def comparar(local, remoto)
|
|
133
|
+
return [] if remoto.nil? && sin_constancia_esperada?(local)
|
|
134
|
+
return [divergencia(:no_consta, local, nil, detalle_no_consta(local))] if remoto.nil?
|
|
135
|
+
return [divergencia(:consta_sin_enviar, local, remoto, detalle_sin_enviar(local, remoto))] if sin_enviar?(local)
|
|
136
|
+
|
|
137
|
+
# Si la huella no coincide, lo que la AEAT guarda NO es este registro, y
|
|
138
|
+
# comparar además el estado solo añadiría ruido sobre otro documento.
|
|
139
|
+
if remoto.huella != local.huella
|
|
140
|
+
return [divergencia(:huella_distinta, local, remoto,
|
|
141
|
+
"la AEAT guarda la huella #{remoto.huella} y el libro " \
|
|
142
|
+
"#{local.huella} para esta factura")]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
esperado = estado_esperado(local)
|
|
146
|
+
return [] if remoto.estado == esperado
|
|
147
|
+
|
|
148
|
+
[divergencia(:estado_distinto, local, remoto,
|
|
149
|
+
"la AEAT dice #{remoto.estado} y el libro esperaba #{esperado}")]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Que no conste no siempre es divergencia: lo que aún no se ha enviado no
|
|
153
|
+
# tiene por qué estar, y un registro RECHAZADO no se almacena en la AEAT,
|
|
154
|
+
# así que su ausencia es justo lo correcto.
|
|
155
|
+
def sin_constancia_esperada?(local)
|
|
156
|
+
sin_enviar?(local) || local.estado == 'rechazado'
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def sin_enviar?(local) = %w[pendiente enviando].include?(local.estado)
|
|
160
|
+
|
|
161
|
+
def estado_esperado(local)
|
|
162
|
+
return 'Anulado' if local.tipo == 'anulacion'
|
|
163
|
+
return 'AceptadoConErrores' if local.estado == 'aceptado_con_errores'
|
|
164
|
+
|
|
165
|
+
'Correcto'
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def detalle_no_consta(local)
|
|
169
|
+
"el libro la da por #{local.estado} pero la AEAT no la devuelve; " \
|
|
170
|
+
'no consta remitida'
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def detalle_sin_enviar(local, remoto)
|
|
174
|
+
coincide = remoto.huella == local.huella ? 'y la huella coincide' : 'pero con OTRA huella'
|
|
175
|
+
"el libro la tiene en '#{local.estado}' y la AEAT ya la tiene anotada " \
|
|
176
|
+
"#{coincide}: el envío llegó y no nos enteramos"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def solo_en_aeat(fila)
|
|
180
|
+
Divergencia.new(
|
|
181
|
+
tipo: :solo_en_aeat, id_emisor: fila.id_emisor, num_serie: fila.num_serie,
|
|
182
|
+
fecha_expedicion: fila.fecha_expedicion, local: nil, remoto: fila,
|
|
183
|
+
detalle: 'la AEAT tiene esta factura en esta instalación y el libro no ' \
|
|
184
|
+
'la conoce: o factura otro sistema para este obligado, o se ' \
|
|
185
|
+
'reutilizó un número de instalación con historia'
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def divergencia(tipo, local, remoto, detalle)
|
|
190
|
+
Divergencia.new(tipo: tipo, id_emisor: local.id_emisor,
|
|
191
|
+
num_serie: local.num_serie,
|
|
192
|
+
fecha_expedicion: local.fecha_expedicion,
|
|
193
|
+
local: local, remoto: remoto, detalle: detalle)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def descargar(ejercicio:, periodo:)
|
|
197
|
+
filas = []
|
|
198
|
+
clave = nil
|
|
199
|
+
|
|
200
|
+
MAXIMO_PAGINAS.times do
|
|
201
|
+
respuesta = pedir(ejercicio: ejercicio, periodo: periodo, clave: clave)
|
|
202
|
+
filas.concat(respuesta.registros)
|
|
203
|
+
|
|
204
|
+
return [filas, false] unless respuesta.hay_mas_paginas?
|
|
205
|
+
|
|
206
|
+
clave = respuesta.clave_paginacion
|
|
207
|
+
# Paginación "S" sin clave dejaría un bucle infinito pidiendo lo mismo.
|
|
208
|
+
return [filas, false] if clave.nil?
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
[filas, true]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def pedir(ejercicio:, periodo:, clave:)
|
|
215
|
+
consulta = Consulta.new(
|
|
216
|
+
nif_obligado: cadena.nif_obligado, nombre_obligado: cadena.nombre_obligado,
|
|
217
|
+
ejercicio: ejercicio, periodo: periodo,
|
|
218
|
+
sistema_informatico: cadena.sistema_informatico, clave_paginacion: clave
|
|
219
|
+
)
|
|
220
|
+
RespuestaConsulta.new(transporte.enviar(consulta.to_xml).fetch(:cuerpo))
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VerifactuRails
|
|
4
|
+
module Libro
|
|
5
|
+
# Una anotación del libro registro: un registro de facturación tal y como se
|
|
6
|
+
# generó, con su sitio en la cadena.
|
|
7
|
+
#
|
|
8
|
+
# Es el SISTEMA DE REGISTRO, no una caché de lo que tiene la AEAT. Está
|
|
9
|
+
# comprobado que la consulta de la AEAT devuelve una foto por factura y no el
|
|
10
|
+
# histórico: los eslabones sustituidos desaparecen de allí. Si esta tabla se
|
|
11
|
+
# pierde, el histórico de la cadena no existe en ninguna otra parte.
|
|
12
|
+
class Registro < ActiveRecord::Base
|
|
13
|
+
self.table_name = 'verifactu_registros'
|
|
14
|
+
|
|
15
|
+
# Lo que define la posición en la cadena no se toca nunca más. El estado del
|
|
16
|
+
# envío sí cambia; el encadenamiento no.
|
|
17
|
+
attr_readonly :cadena_id, :tipo, :id_emisor, :num_serie, :fecha_expedicion,
|
|
18
|
+
:tipo_factura, :cuota_total, :importe_total,
|
|
19
|
+
:huella, :huella_anterior, :fecha_hora_gen, :payload,
|
|
20
|
+
:payload_version, :qr_url
|
|
21
|
+
|
|
22
|
+
ESTADOS = %w[pendiente enviando anotado aceptado_con_errores rechazado].freeze
|
|
23
|
+
|
|
24
|
+
belongs_to :cadena, class_name: 'VerifactuRails::Libro::Cadena'
|
|
25
|
+
|
|
26
|
+
scope :pendientes, -> { where(estado: 'pendiente').order(:id) }
|
|
27
|
+
scope :con_anomalias, -> { where.not(anomalias: nil) }
|
|
28
|
+
|
|
29
|
+
def primero? = huella_anterior == ''
|
|
30
|
+
def anomalias? = anomalias.present?
|
|
31
|
+
|
|
32
|
+
def anomalias_lista
|
|
33
|
+
anomalias.nil? ? [] : JSON.parse(anomalias).map(&:to_sym)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# El eslabón anterior, buscado por su huella. Funciona porque (cadena_id,
|
|
37
|
+
# huella) es único.
|
|
38
|
+
def anterior
|
|
39
|
+
return nil if primero?
|
|
40
|
+
|
|
41
|
+
cadena.registros.find_by(huella: huella_anterior)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Para encadenar el siguiente.
|
|
45
|
+
def a_registro_anterior
|
|
46
|
+
RegistroAnterior.new(id_emisor: id_emisor, num_serie: num_serie,
|
|
47
|
+
fecha_expedicion: fecha_expedicion, huella: huella)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# La marca temporal tal y como entró en la huella, con su huso.
|
|
51
|
+
def momento = Time.iso8601(fecha_hora_gen)
|
|
52
|
+
|
|
53
|
+
# Recalcula la huella desde las columnas, sin tocar el payload ni volver a
|
|
54
|
+
# construir el objeto de dominio. Es lo que permite detectar que alguien
|
|
55
|
+
# editó la fila: si el recálculo no da lo almacenado, la anotación está
|
|
56
|
+
# alterada.
|
|
57
|
+
def huella_recalculada
|
|
58
|
+
if tipo == 'anulacion'
|
|
59
|
+
Huella.anulacion(id_emisor: id_emisor, num_serie: num_serie,
|
|
60
|
+
fecha_expedicion: fecha_expedicion,
|
|
61
|
+
fecha_hora_gen: fecha_hora_gen,
|
|
62
|
+
huella_anterior: huella_anterior)
|
|
63
|
+
else
|
|
64
|
+
Huella.alta(id_emisor: id_emisor, num_serie: num_serie,
|
|
65
|
+
fecha_expedicion: fecha_expedicion, tipo_factura: tipo_factura,
|
|
66
|
+
cuota_total: cuota_total, importe_total: importe_total,
|
|
67
|
+
fecha_hora_gen: fecha_hora_gen, huella_anterior: huella_anterior)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def huella_cuadra? = huella_recalculada == huella
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VerifactuRails
|
|
4
|
+
module Libro
|
|
5
|
+
# Remisión a la AEAT de los registros que una cadena tiene pendientes.
|
|
6
|
+
#
|
|
7
|
+
# Va SEPARADO de anotar! a propósito. El encadenamiento es síncrono y bajo
|
|
8
|
+
# lock porque no se puede diferir; el envío es asíncrono y reintentable
|
|
9
|
+
# porque sí. Confundir las dos cosas es lo que llevaría a bloquear una venta
|
|
10
|
+
# porque la AEAT no responde.
|
|
11
|
+
#
|
|
12
|
+
# Sobre el tamaño del lote: agrupar NO es el modo normal. Las FAQs exigen que
|
|
13
|
+
# la remisión sea "inmediata o sin demora apreciable" a la expedición, así que
|
|
14
|
+
# un comercio que factura cada diez minutos mandará siempre un registro por
|
|
15
|
+
# petición. El tope de 1000 es un techo para quien factura rápido, y el lote
|
|
16
|
+
# acaba siendo del tamaño que dicte el ritmo de facturación, no una decisión.
|
|
17
|
+
class Remesa
|
|
18
|
+
MAXIMO = Envio::MAXIMO_REGISTROS
|
|
19
|
+
|
|
20
|
+
# Se envían también los que quedaron 'enviando': si un envío dio timeout no
|
|
21
|
+
# se sabe si llegó, y reintentar es seguro porque un duplicado se trata como
|
|
22
|
+
# éxito. Dejarlos fuera los abandonaría para siempre.
|
|
23
|
+
ENVIABLES = %w[pendiente enviando].freeze
|
|
24
|
+
|
|
25
|
+
Resultado = Struct.new(:estado, :respuesta, :enviados, keyword_init: true) do
|
|
26
|
+
def enviado? = estado == :enviado
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(cadena, transporte:)
|
|
30
|
+
@cadena = cadena
|
|
31
|
+
@transporte = transporte
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Resultado] con estado :enviado, :esperando, :nada_pendiente o
|
|
35
|
+
# :bloqueada_por_rechazo
|
|
36
|
+
def enviar!(ahora: Time.now)
|
|
37
|
+
return sin_enviar(:esperando) if esperando?(ahora)
|
|
38
|
+
return sin_enviar(:bloqueada_por_rechazo) if rechazo_sin_resolver?
|
|
39
|
+
|
|
40
|
+
lote = cadena.registros.where(estado: ENVIABLES).order(:id).limit(MAXIMO).to_a
|
|
41
|
+
return sin_enviar(:nada_pendiente) if lote.empty?
|
|
42
|
+
|
|
43
|
+
marcar_enviando(lote)
|
|
44
|
+
respuesta = transmitir(lote)
|
|
45
|
+
aplicar!(lote, respuesta)
|
|
46
|
+
cadena.update!(no_enviar_antes_de: ahora + respuesta.tiempo_espera.to_i)
|
|
47
|
+
|
|
48
|
+
Resultado.new(estado: :enviado, respuesta: respuesta, enviados: lote)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
attr_reader :cadena, :transporte
|
|
54
|
+
|
|
55
|
+
def sin_enviar(estado) = Resultado.new(estado: estado, enviados: [])
|
|
56
|
+
|
|
57
|
+
def esperando?(ahora)
|
|
58
|
+
cadena.no_enviar_antes_de.present? && cadena.no_enviar_antes_de > ahora
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Un registro rechazado NO consta en la AEAT, así que todo lo que encadena
|
|
62
|
+
# detrás apunta a un eslabón que allí no existe. Seguir enviando funcionaría
|
|
63
|
+
# -está comprobado que la AEAT no valida el eslabón al recibir- y dejaría una
|
|
64
|
+
# cadena incoherente aceptada en silencio. Se para y se avisa: resolver un
|
|
65
|
+
# rechazo es una decisión de negocio, no algo que un job deba improvisar.
|
|
66
|
+
def rechazo_sin_resolver?
|
|
67
|
+
cadena.registros.exists?(estado: 'rechazado')
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def marcar_enviando(lote)
|
|
71
|
+
cadena.registros.where(id: lote.map(&:id)).update_all(estado: 'enviando')
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def transmitir(lote)
|
|
75
|
+
xml = Envio.xml_desde_fragmentos(
|
|
76
|
+
nif_obligado: cadena.nif_obligado, nombre_obligado: cadena.nombre_obligado,
|
|
77
|
+
fragmentos: lote.map(&:payload)
|
|
78
|
+
)
|
|
79
|
+
Respuesta.new(transporte.enviar(xml).fetch(:cuerpo))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def aplicar!(lote, respuesta)
|
|
83
|
+
por_serie = respuesta.lineas.group_by(&:num_serie)
|
|
84
|
+
lote.each do |fila|
|
|
85
|
+
linea = por_serie[fila.num_serie]&.shift
|
|
86
|
+
next if linea.nil? # sin veredicto: se queda 'enviando' y se reintenta
|
|
87
|
+
|
|
88
|
+
fila.update!(estado: estado_de(linea), csv: respuesta.csv,
|
|
89
|
+
codigo_error: linea.codigo_error,
|
|
90
|
+
descripcion_error: descripcion_de(linea),
|
|
91
|
+
enviado_at: Time.now)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Un duplicado es un ÉXITO, no un fallo: significa que el registro ya consta
|
|
96
|
+
# en la AEAT, que es justo lo que se quería. Pasa al reintentar un envío que
|
|
97
|
+
# dio timeout. La validación local de numeración hace que un choque real no
|
|
98
|
+
# pueda existir en la tabla, así que este caso es casi siempre el reintento.
|
|
99
|
+
#
|
|
100
|
+
# "Casi siempre" no es "siempre": podría venir de otro sistema facturando
|
|
101
|
+
# para el mismo obligado. Por eso el estado del duplicado se guarda en la
|
|
102
|
+
# descripción en vez de tragárselo, y si figura Anulada hay un problema real.
|
|
103
|
+
def estado_de(linea)
|
|
104
|
+
return 'anotado' if linea.duplicado?
|
|
105
|
+
return 'anotado' if linea.correcto?
|
|
106
|
+
return 'aceptado_con_errores' if linea.aceptado_con_errores?
|
|
107
|
+
|
|
108
|
+
'rechazado'
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def descripcion_de(linea)
|
|
112
|
+
return linea.descripcion_error unless linea.duplicado?
|
|
113
|
+
|
|
114
|
+
"Duplicado: el registro ya constaba en la AEAT con estado " \
|
|
115
|
+
"#{linea.duplicado.estado} (petición #{linea.duplicado.id_peticion}). " \
|
|
116
|
+
"#{linea.descripcion_error}".strip
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
require 'active_record'
|
|
7
|
+
rescue LoadError
|
|
8
|
+
raise LoadError, <<~TXT
|
|
9
|
+
verifactu_rails/libro necesita ActiveRecord y no se ha podido cargar.
|
|
10
|
+
|
|
11
|
+
La gema lo declara como dependencia, así que esto suele significar un bundle
|
|
12
|
+
a medias o que se ha copiado lib/ a mano. Con `bundle install` debería bastar.
|
|
13
|
+
|
|
14
|
+
Si lo que quieres es solo calcular huellas y generar XML, no cargues esta
|
|
15
|
+
capa: `require 'verifactu-rails'` no necesita ActiveRecord para nada.
|
|
16
|
+
TXT
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
require_relative '../verifactu-rails'
|
|
20
|
+
|
|
21
|
+
module VerifactuRails
|
|
22
|
+
# El libro registro: la capa de persistencia.
|
|
23
|
+
#
|
|
24
|
+
# Se carga APARTE del núcleo (`require 'verifactu_rails/libro'`), porque exige
|
|
25
|
+
# ActiveRecord y el núcleo no depende de Rails. Quien solo quiera generar XML y
|
|
26
|
+
# huellas no arrastra nada de esto.
|
|
27
|
+
#
|
|
28
|
+
# POR QUÉ ESTA CAPA EXISTE. No es una caché de lo que tiene la AEAT: es el
|
|
29
|
+
# sistema de registro. Está comprobado que la consulta de la AEAT devuelve una
|
|
30
|
+
# foto por factura, no el histórico, así que los eslabones sustituidos por una
|
|
31
|
+
# subsanación desaparecen de allí. El histórico de la cadena no existe en
|
|
32
|
+
# ninguna otra parte.
|
|
33
|
+
module Libro
|
|
34
|
+
# Lo que identifica al SOFTWARE. Lo que identifica a la INSTALACIÓN
|
|
35
|
+
# (NumeroInstalacion) y al obligado va en la tabla de cadenas, no aquí: hay
|
|
36
|
+
# una instalación por fuente de facturación, no una por despliegue.
|
|
37
|
+
Configuracion = Struct.new(:productor_nombre, :productor_nif, :nombre_sistema,
|
|
38
|
+
:id_sistema, :version, :multi_ot, :multiples_ot,
|
|
39
|
+
:entorno, :al_detectar_anomalia,
|
|
40
|
+
keyword_init: true) do
|
|
41
|
+
def sistema_para(cadena)
|
|
42
|
+
SistemaInformatico.new(
|
|
43
|
+
nombre_razon: productor_nombre, nif: productor_nif,
|
|
44
|
+
nombre_sistema: nombre_sistema, id_sistema: id_sistema, version: version,
|
|
45
|
+
numero_instalacion: cadena.numero_instalacion,
|
|
46
|
+
multi_ot: multi_ot || false, multiples_ot: multiples_ot || false
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
def configuracion
|
|
53
|
+
@configuracion ||= Configuracion.new(entorno: :pruebas)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def configure = yield(configuracion)
|
|
57
|
+
|
|
58
|
+
# El fragmento XML del registro, ya construido y con su huella dentro. Se
|
|
59
|
+
# guarda hecho para que lo que se envíe sea exactamente lo que se calculó:
|
|
60
|
+
# si nadie vuelve a derivarlo, la huella y el XML no pueden divergir.
|
|
61
|
+
#
|
|
62
|
+
# Los namespaces se declaran en el propio fragmento aunque luego se anide
|
|
63
|
+
# dentro del envío, que los repite: es redundante pero válido, y hace que
|
|
64
|
+
# cada fragmento se pueda leer y validar por separado.
|
|
65
|
+
def fragmento_xml(registro, anterior)
|
|
66
|
+
constructor = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
|
|
67
|
+
xml['sum'].RegistroFactura('xmlns:sum' => NS_LR, 'xmlns:sum1' => NS_SF) do
|
|
68
|
+
registro.construir(xml, anterior: anterior)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
constructor.doc.root.to_xml
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Las anomalías del art. 7.i no interrumpen la facturación, pero tienen que
|
|
75
|
+
# llegar a alguien. Por defecto no se hace nada: quien despliega decide si
|
|
76
|
+
# las manda a un log, a Sentry o a un panel.
|
|
77
|
+
def avisar_de(anomalias, registro)
|
|
78
|
+
configuracion.al_detectar_anomalia&.call(anomalias, registro)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
require_relative 'libro/migracion'
|
|
85
|
+
require_relative 'libro/cadena'
|
|
86
|
+
require_relative 'libro/registro'
|
|
87
|
+
require_relative 'libro/autochequeo'
|
|
88
|
+
require_relative 'libro/remesa'
|
|
89
|
+
require_relative 'libro/reconciliacion'
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
require_relative 'error'
|
|
5
|
+
|
|
6
|
+
module VerifactuRails
|
|
7
|
+
# URL de cotejo del código QR que debe llevar la factura.
|
|
8
|
+
#
|
|
9
|
+
# La AEAT NO devuelve esta URL en ninguna respuesta: la construye el propio SIF
|
|
10
|
+
# con datos que ya tiene. Por eso el QR no depende del envío y se puede generar
|
|
11
|
+
# en el momento de crear el registro, aunque la AEAT esté caída.
|
|
12
|
+
#
|
|
13
|
+
# Ojo al matiz: que el QR sea válido no significa que la factura conste. Si el
|
|
14
|
+
# envío nunca llega a completarse, quien escanee obtendrá un "no consta".
|
|
15
|
+
module QR
|
|
16
|
+
# El host NO es el de los envíos: el cotejo va por prewww2 / www2, mientras
|
|
17
|
+
# que el SOAP va por prewww1/prewww10 y www1/www10.
|
|
18
|
+
BASES = {
|
|
19
|
+
pruebas: 'https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR',
|
|
20
|
+
produccion: 'https://www2.agenciatributaria.gob.es/wlpl/TIKE-CONT/ValidarQR'
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# @param registro [RegistroAlta]
|
|
26
|
+
def url(registro, entorno: :pruebas)
|
|
27
|
+
componer(nif: registro.id_emisor, num_serie: registro.num_serie,
|
|
28
|
+
fecha: registro.fecha_expedicion, importe: registro.importe_total,
|
|
29
|
+
entorno: entorno)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Los cuatro parámetros sueltos. Se expone aparte del registro para poder
|
|
33
|
+
# reproducir el ejemplo oficial del PDF, cuya fecha (01-01-2024) es anterior
|
|
34
|
+
# a la entrada en vigor y por tanto no se puede montar como RegistroAlta.
|
|
35
|
+
def componer(nif:, num_serie:, fecha:, importe:, entorno: :pruebas)
|
|
36
|
+
base = BASES.fetch(entorno) do
|
|
37
|
+
raise ValidacionError, "Entorno desconocido para el QR: #{entorno.inspect}"
|
|
38
|
+
end
|
|
39
|
+
pares = { 'nif' => nif, 'numserie' => num_serie,
|
|
40
|
+
'fecha' => fecha, 'importe' => importe }
|
|
41
|
+
|
|
42
|
+
"#{base}?#{pares.map { |k, v| "#{k}=#{codificar(v)}" }.join('&')}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# El PDF de especificaciones dedica un apartado a esto y pone como
|
|
46
|
+
# contraejemplo un numserie=12345678&G33 sin codificar, que parte la URL. Y el
|
|
47
|
+
# caso es alcanzable: Formato.num_serie prohíbe " ' < > = pero SÍ admite &, %,
|
|
48
|
+
# + y espacios.
|
|
49
|
+
#
|
|
50
|
+
# El espacio se codifica como %20 y no como "+": ambos son válidos en un query
|
|
51
|
+
# string, pero "+" solo se interpreta como espacio bajo
|
|
52
|
+
# application/x-www-form-urlencoded, y aquí no hay forma de saber cómo lo
|
|
53
|
+
# decodifica la AEAT. %20 significa espacio en cualquier lectura.
|
|
54
|
+
def codificar(valor) = URI.encode_www_form_component(valor.to_s).gsub('+', '%20')
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VerifactuRails
|
|
4
|
+
# Enganche con Rails. Se carga SOLO si Rails está delante (ver el final de
|
|
5
|
+
# lib/verifactu-rails.rb): el núcleo -huella, XML, transporte- no depende de
|
|
6
|
+
# Rails y no debe empezar a depender por esto.
|
|
7
|
+
#
|
|
8
|
+
# Hace una sola cosa: cargar la capa Libro cuando ActiveRecord esté listo.
|
|
9
|
+
#
|
|
10
|
+
# Fuera de Rails esa capa es opt-in a propósito (`require
|
|
11
|
+
# 'verifactu_rails/libro'`), porque exige ActiveRecord y quien solo quiera
|
|
12
|
+
# calcular huellas no tiene por qué arrastrarlo. Dentro de Rails ActiveRecord
|
|
13
|
+
# está siempre, así que mantener el require manual solo conseguiría que
|
|
14
|
+
# `Cadena` no existiera hasta que alguien se acordara de escribirlo.
|
|
15
|
+
#
|
|
16
|
+
# Va por `on_load(:active_record)` y no por un require directo porque
|
|
17
|
+
# libro.rb declara modelos, y declarar un modelo fuerza la carga de
|
|
18
|
+
# ActiveRecord::Base. Hacerlo en el arranque le quita a la app la carga
|
|
19
|
+
# diferida para todo el resto.
|
|
20
|
+
#
|
|
21
|
+
# Los generadores NO se registran aquí: Rails los descubre solo buscando
|
|
22
|
+
# lib/generators/**/*_generator.rb por el $LOAD_PATH.
|
|
23
|
+
class Railtie < ::Rails::Railtie
|
|
24
|
+
initializer 'verifactu.libro' do
|
|
25
|
+
ActiveSupport.on_load(:active_record) do
|
|
26
|
+
require 'verifactu_rails/libro'
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|