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,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'date'
|
|
5
|
+
require_relative 'error'
|
|
6
|
+
|
|
7
|
+
module VerifactuRails
|
|
8
|
+
# Normalización canónica de los valores que aparecen a la vez en la cadena de la
|
|
9
|
+
# huella y en el XML.
|
|
10
|
+
#
|
|
11
|
+
# REGLA DE ORO, la misma que rige Importe: la AEAT recalcula la huella sobre lo
|
|
12
|
+
# que recibe en el XML. Si un valor se normaliza distinto en cada sitio, rechazo.
|
|
13
|
+
# Por eso fechas y marcas temporales viven aquí y no duplicadas en cada módulo.
|
|
14
|
+
#
|
|
15
|
+
# (El escapado XML es harina de otro costal y no rompe esta regla: la huella se
|
|
16
|
+
# calcula sobre el valor crudo y el XML lo escapa, pero la AEAT desescapa antes
|
|
17
|
+
# de recalcular. Una serie "A&B" va cruda a la huella y como "A&B" al XML.)
|
|
18
|
+
module Formato
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Espacios al inicio y al final: la spec NO es ambigua, dice recortarlos.
|
|
22
|
+
#
|
|
23
|
+
# "Los valores de los campos deberán tener la misma información contenida
|
|
24
|
+
# en el campo correspondiente del fichero XML, pero eliminando los
|
|
25
|
+
# espacios al inicio y al final de cada valor"
|
|
26
|
+
# (Especificaciones huella v0.1.2, ap. 3)
|
|
27
|
+
#
|
|
28
|
+
# Aquí rechazamos en vez de recortar, que es MÁS ESTRICTO que la norma: un
|
|
29
|
+
# valor con espacios al borde casi siempre es un defecto de los datos de
|
|
30
|
+
# origen, y recortar en silencio lo taparía. Los espacios interiores sí se
|
|
31
|
+
# respetan ("12345678 / G33" es un NumSerieFactura válido y así lo ejemplifica
|
|
32
|
+
# la propia AEAT).
|
|
33
|
+
def texto(valor, campo)
|
|
34
|
+
cadena = valor.to_s
|
|
35
|
+
raise ValidacionError, "#{campo} no puede estar vacío" if cadena.empty?
|
|
36
|
+
|
|
37
|
+
if cadena != cadena.strip
|
|
38
|
+
raise ValidacionError,
|
|
39
|
+
"#{campo} contiene espacios al inicio o final: #{cadena.inspect}. " \
|
|
40
|
+
'Normalízalo antes de generar el registro.'
|
|
41
|
+
end
|
|
42
|
+
cadena
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Tipo sf:fecha del XSD: exactamente dd-mm-yyyy.
|
|
46
|
+
def fecha(valor)
|
|
47
|
+
objeto = valor.is_a?(String) ? Date.strptime(valor, '%d-%m-%Y') : valor
|
|
48
|
+
# Guarda explícita, como en marca_temporal: sin ella, un nil se escapaba
|
|
49
|
+
# del contrato de errores del módulo con un NoMethodError en vez de un
|
|
50
|
+
# ValidacionError, porque el rescue de abajo no captura NoMethodError.
|
|
51
|
+
unless objeto.respond_to?(:strftime)
|
|
52
|
+
raise ValidacionError, "Fecha inválida (se espera Date o 'dd-mm-yyyy'): #{valor.inspect}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
objeto.strftime('%d-%m-%Y')
|
|
56
|
+
rescue ArgumentError, TypeError
|
|
57
|
+
raise ValidacionError, "Fecha inválida (se espera Date o 'dd-mm-yyyy'): #{valor.inspect}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# ISO 8601 con offset explícito. OJO: Ruby serializa UTC como "Z" mientras
|
|
61
|
+
# que la referencia usa "+00:00"; forzamos siempre ±HH:MM para que la huella
|
|
62
|
+
# coincida con la de otras implementaciones y con el XML.
|
|
63
|
+
def marca_temporal(valor)
|
|
64
|
+
tiempo = valor.is_a?(String) ? Time.iso8601(valor) : valor
|
|
65
|
+
raise ValidacionError, 'fecha_hora_gen debe ser Time o String ISO 8601' unless tiempo.respond_to?(:strftime)
|
|
66
|
+
|
|
67
|
+
tiempo.strftime('%Y-%m-%dT%H:%M:%S%:z')
|
|
68
|
+
rescue ArgumentError => e
|
|
69
|
+
raise ValidacionError, "fecha_hora_gen inválida: #{valor.inspect} (#{e.message})"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Longitud fija de 9 según sf:NIFType. El XSD no valida el dígito de control,
|
|
73
|
+
# y nosotros tampoco: rechazar un NIF válido por una tabla desactualizada
|
|
74
|
+
# sería peor que dejar que la AEAT lo rechace.
|
|
75
|
+
# Se normaliza a MAYÚSCULAS, y esto sí es una excepción deliberada a la regla
|
|
76
|
+
# de "rechazar en vez de arreglar" que rige `texto`.
|
|
77
|
+
#
|
|
78
|
+
# Motivo, comprobado contra preproducción: un NIF con la letra en minúscula
|
|
79
|
+
# ("89890001k") supera la validación de FORMATO de la AEAT (no da 4116) pero
|
|
80
|
+
# falla la búsqueda en el censo, que sí distingue mayúsculas, y devuelve un
|
|
81
|
+
# 4104 "no está identificado" que apunta al sitio equivocado: parece que el
|
|
82
|
+
# NIF no existe cuando lo único que pasa es que va en minúscula.
|
|
83
|
+
#
|
|
84
|
+
# La diferencia con los espacios al borde es que ahí el valor original podía
|
|
85
|
+
# significar algo (un campo de ancho fijo mal recortado, por ejemplo) y
|
|
86
|
+
# recortar lo taparía. Aquí no: la forma canónica de un NIF es en mayúsculas
|
|
87
|
+
# y "c" y "C" denotan la misma letra de control. No se pierde información.
|
|
88
|
+
def nif(valor, campo = 'NIF')
|
|
89
|
+
cadena = texto(valor, campo).upcase
|
|
90
|
+
unless cadena.length == 9
|
|
91
|
+
raise ValidacionError, "#{campo} debe tener 9 caracteres: #{cadena.inspect}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
cadena
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# NumSerieFactura solo admite ASCII imprimible (32-126) y prohíbe además
|
|
98
|
+
# cinco caracteres concretos (Validaciones v1.2.2, ap. 3.1.3.1).
|
|
99
|
+
#
|
|
100
|
+
# Ojo a la asimetría: "&" SÍ está permitido, y es justo el que obliga a
|
|
101
|
+
# escapar en el XML mientras la huella lo usa crudo. Los que romperían el
|
|
102
|
+
# XML de verdad (< > ") están prohibidos de entrada por la AEAT.
|
|
103
|
+
PROHIBIDOS_SERIE = ['"', "'", '<', '>', '='].freeze
|
|
104
|
+
|
|
105
|
+
def num_serie(valor, campo = 'NumSerieFactura')
|
|
106
|
+
cadena = limitar(valor, campo, 60)
|
|
107
|
+
|
|
108
|
+
malos = cadena.chars.reject { |c| c.ord.between?(32, 126) }.uniq
|
|
109
|
+
unless malos.empty?
|
|
110
|
+
raise ValidacionError,
|
|
111
|
+
"#{campo} solo admite ASCII imprimible (32-126); sobran: " \
|
|
112
|
+
"#{malos.map(&:inspect).join(', ')}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
encontrados = PROHIBIDOS_SERIE.select { |c| cadena.include?(c) }
|
|
116
|
+
unless encontrados.empty?
|
|
117
|
+
raise ValidacionError,
|
|
118
|
+
"#{campo} no admite los caracteres #{encontrados.map(&:inspect).join(', ')}: " \
|
|
119
|
+
"#{cadena.inspect}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
cadena
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def limitar(valor, campo, maximo)
|
|
126
|
+
cadena = texto(valor, campo)
|
|
127
|
+
if cadena.length > maximo
|
|
128
|
+
raise ValidacionError,
|
|
129
|
+
"#{campo} excede #{maximo} caracteres (#{cadena.length}): #{cadena.inspect}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
cadena
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Campos sf:SiNoType. Acepta el booleano de Ruby y también la letra de la
|
|
136
|
+
# AEAT, porque quien lee la documentación escribe 'S'/'N' de forma natural.
|
|
137
|
+
#
|
|
138
|
+
# Lo que NO puede pasar es tratarlo como valor de verdad a secas: 'N' es
|
|
139
|
+
# truthy en Ruby, así que `multi_ot ? 'S' : 'N'` emitía 'S' cuando el usuario
|
|
140
|
+
# había pedido 'N', invirtiendo la declaración en silencio.
|
|
141
|
+
def si_no(valor, campo)
|
|
142
|
+
case valor
|
|
143
|
+
when nil then nil
|
|
144
|
+
when true, 'S' then 'S'
|
|
145
|
+
when false, 'N' then 'N'
|
|
146
|
+
else
|
|
147
|
+
raise ValidacionError,
|
|
148
|
+
"#{campo} debe ser true/false o 'S'/'N' (recibido: #{valor.inspect})"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Colección homogénea de objetos de valor.
|
|
153
|
+
#
|
|
154
|
+
# No basta con Array(): Array(hash) devuelve los PARES del hash, así que un
|
|
155
|
+
# `destinatarios: {nombre_razon: 'X', nif: '...'}` se convertía en dos
|
|
156
|
+
# "destinatarios" que pasaban las validaciones de conteo y solo reventaban
|
|
157
|
+
# al serializar, con un NoMethodError sobre un Array. Aquí el criterio es el
|
|
158
|
+
# mismo que en el resto de la gema: si el objeto se construye, es emitible.
|
|
159
|
+
def coleccion(valor, campo, clase, maximo: nil)
|
|
160
|
+
lista = valor.nil? ? [] : Array(valor)
|
|
161
|
+
|
|
162
|
+
if valor.is_a?(Hash) || (valor && !valor.is_a?(Array) && !valor.is_a?(clase))
|
|
163
|
+
raise ValidacionError,
|
|
164
|
+
"#{campo} debe ser un #{clase} o un array de #{clase} " \
|
|
165
|
+
"(recibido: #{valor.class})"
|
|
166
|
+
end
|
|
167
|
+
lista = [valor] if valor.is_a?(clase)
|
|
168
|
+
|
|
169
|
+
intrusos = lista.reject { |e| e.is_a?(clase) }.map(&:class).uniq
|
|
170
|
+
unless intrusos.empty?
|
|
171
|
+
raise ValidacionError,
|
|
172
|
+
"#{campo} solo admite #{clase} (encontrados: #{intrusos.join(', ')})"
|
|
173
|
+
end
|
|
174
|
+
if maximo && lista.size > maximo
|
|
175
|
+
raise ValidacionError,
|
|
176
|
+
"#{campo} admite como mucho #{maximo} elementos (recibidos #{lista.size})"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
lista
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Colaborador obligatorio de una clase concreta.
|
|
183
|
+
def objeto(valor, campo, clase)
|
|
184
|
+
return valor if valor.is_a?(clase)
|
|
185
|
+
|
|
186
|
+
raise ValidacionError, "#{campo} debe ser un #{clase} (recibido: #{valor.class})"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def enumerado(valor, campo, admitidos)
|
|
190
|
+
cadena = texto(valor, campo)
|
|
191
|
+
unless admitidos.include?(cadena)
|
|
192
|
+
raise ValidacionError,
|
|
193
|
+
"#{campo} inválido: #{cadena.inspect}. Admitidos: #{admitidos.join(', ')}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
cadena
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require_relative 'importe'
|
|
5
|
+
require_relative 'formato'
|
|
6
|
+
require_relative 'error'
|
|
7
|
+
|
|
8
|
+
module VerifactuRails
|
|
9
|
+
# Cálculo de la huella (hash) de los registros de facturación.
|
|
10
|
+
#
|
|
11
|
+
# Referencia normativa: RD 1007/2023 art. 7, Orden HAC/1177/2024, y el documento
|
|
12
|
+
# "Detalle de las especificaciones técnicas para la generación de la huella o hash
|
|
13
|
+
# de los registros" publicado en la web de desarrolladores de la AEAT.
|
|
14
|
+
#
|
|
15
|
+
# La cadena se construye como pares "Campo=valor" unidos por "&", SIN escapado
|
|
16
|
+
# de los valores, y se le aplica SHA-256 devuelto en hexadecimal MAYÚSCULAS.
|
|
17
|
+
module Huella
|
|
18
|
+
PATRON_HUELLA = /\A[0-9A-F]{64}\z/
|
|
19
|
+
CAMPOS_ALTA = %w[
|
|
20
|
+
IDEmisorFactura NumSerieFactura FechaExpedicionFactura
|
|
21
|
+
TipoFactura CuotaTotal ImporteTotal Huella FechaHoraHusoGenRegistro
|
|
22
|
+
].freeze
|
|
23
|
+
CAMPOS_ANULACION = %w[
|
|
24
|
+
IDEmisorFacturaAnulada NumSerieFacturaAnulada FechaExpedicionFacturaAnulada
|
|
25
|
+
Huella FechaHoraHusoGenRegistro
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Huella de un registro de facturación de ALTA.
|
|
31
|
+
#
|
|
32
|
+
# @param huella_anterior [String, nil] huella del registro previo de la cadena.
|
|
33
|
+
# nil o "" únicamente para el PRIMER registro de ese SIF y NIF obligado.
|
|
34
|
+
# Ojo: la cadena es una sola por SIF+NIF, NO una por serie de facturación.
|
|
35
|
+
def alta(**datos)
|
|
36
|
+
digerir(cadena_alta(**datos))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Cadena exacta sobre la que se calcula la huella de un ALTA.
|
|
40
|
+
#
|
|
41
|
+
# Es pública a propósito y por dos motivos. Uno: cuando la AEAT rechaza por
|
|
42
|
+
# huella, lo primero que hay que comparar es ESTE string, no el digest. Dos:
|
|
43
|
+
# que los tests puedan afirmar sobre la cadena sin reconstruirla por su
|
|
44
|
+
# cuenta, que es como se quedan ciegos ante un cambio de normalizador.
|
|
45
|
+
def cadena_alta(id_emisor:, num_serie:, fecha_expedicion:, tipo_factura:,
|
|
46
|
+
cuota_total:, importe_total:, fecha_hora_gen:, huella_anterior: nil)
|
|
47
|
+
pares = {
|
|
48
|
+
'IDEmisorFactura' => texto(id_emisor, 'IDEmisorFactura'),
|
|
49
|
+
'NumSerieFactura' => texto(num_serie, 'NumSerieFactura'),
|
|
50
|
+
'FechaExpedicionFactura' => fecha(fecha_expedicion),
|
|
51
|
+
'TipoFactura' => texto(tipo_factura, 'TipoFactura'),
|
|
52
|
+
'CuotaTotal' => Importe.formatear(cuota_total),
|
|
53
|
+
'ImporteTotal' => Importe.formatear(importe_total),
|
|
54
|
+
'Huella' => encadenamiento(huella_anterior),
|
|
55
|
+
'FechaHoraHusoGenRegistro' => marca_temporal(fecha_hora_gen)
|
|
56
|
+
}
|
|
57
|
+
serializar(pares, CAMPOS_ALTA)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Huella de un registro de facturación de ANULACIÓN.
|
|
61
|
+
# Aquí huella_anterior es SIEMPRE obligatoria: una anulación nunca puede ser
|
|
62
|
+
# el primer registro de la cadena.
|
|
63
|
+
def anulacion(**datos)
|
|
64
|
+
digerir(cadena_anulacion(**datos))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Cadena exacta sobre la que se calcula la huella de una ANULACIÓN.
|
|
68
|
+
def cadena_anulacion(id_emisor:, num_serie:, fecha_expedicion:,
|
|
69
|
+
fecha_hora_gen:, huella_anterior:)
|
|
70
|
+
if huella_anterior.nil? || huella_anterior.to_s.empty?
|
|
71
|
+
raise ValidacionError,
|
|
72
|
+
'Un registro de anulación exige huella_anterior: no puede iniciar cadena'
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
pares = {
|
|
76
|
+
'IDEmisorFacturaAnulada' => texto(id_emisor, 'IDEmisorFacturaAnulada'),
|
|
77
|
+
'NumSerieFacturaAnulada' => texto(num_serie, 'NumSerieFacturaAnulada'),
|
|
78
|
+
'FechaExpedicionFacturaAnulada' => fecha(fecha_expedicion),
|
|
79
|
+
'Huella' => encadenamiento(huella_anterior),
|
|
80
|
+
'FechaHoraHusoGenRegistro' => marca_temporal(fecha_hora_gen)
|
|
81
|
+
}
|
|
82
|
+
serializar(pares, CAMPOS_ANULACION)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Expone la cadena previa al hash. Imprescindible para depurar: cuando la AEAT
|
|
86
|
+
# rechaza por huella, lo primero es comparar ESTE string, no el digest.
|
|
87
|
+
def serializar(pares, orden)
|
|
88
|
+
orden.map { |campo| "#{campo}=#{pares.fetch(campo)}" }.join('&')
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def digerir(cadena)
|
|
92
|
+
Digest::SHA256.hexdigest(cadena.encode(Encoding::UTF_8)).upcase
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# --- normalizadores -------------------------------------------------------
|
|
96
|
+
#
|
|
97
|
+
# texto, fecha y marca_temporal viven en Formato porque el generador de XML
|
|
98
|
+
# necesita exactamente los mismos: si divergieran, la huella que calculamos
|
|
99
|
+
# dejaría de corresponder al XML que enviamos.
|
|
100
|
+
#
|
|
101
|
+
# DELIBERADO: aquí se usa Formato.texto y NO Formato.num_serie, que es el que
|
|
102
|
+
# aplica las restricciones de la AEAT al NumSerieFactura (ASCII 32-126, sin
|
|
103
|
+
# " ' < > =). Este módulo es una primitiva de hash, no la capa de reglas de
|
|
104
|
+
# negocio: debe poder recalcular la huella de CUALQUIER registro, incluido uno
|
|
105
|
+
# que no generamos nosotros. Eso es justo lo que necesitas al depurar un
|
|
106
|
+
# rechazo de la AEAT o al migrar desde otro sistema.
|
|
107
|
+
#
|
|
108
|
+
# Quien construye registros nuevos pasa por RegistroAlta / RegistroAnulacion,
|
|
109
|
+
# y ahí sí se aplican todas las restricciones.
|
|
110
|
+
|
|
111
|
+
def texto(valor, campo) = Formato.texto(valor, campo)
|
|
112
|
+
|
|
113
|
+
def fecha(valor) = Formato.fecha(valor)
|
|
114
|
+
|
|
115
|
+
def marca_temporal(valor) = Formato.marca_temporal(valor)
|
|
116
|
+
|
|
117
|
+
def encadenamiento(valor)
|
|
118
|
+
return '' if valor.nil? || valor.to_s.empty?
|
|
119
|
+
|
|
120
|
+
cadena = valor.to_s
|
|
121
|
+
unless cadena.match?(PATRON_HUELLA)
|
|
122
|
+
raise ValidacionError,
|
|
123
|
+
"huella_anterior debe ser SHA-256 hex en MAYÚSCULAS (64 chars): #{cadena.inspect}"
|
|
124
|
+
end
|
|
125
|
+
cadena
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private_class_method :texto, :fecha, :encadenamiento, :marca_temporal
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bigdecimal'
|
|
4
|
+
require 'bigdecimal/util'
|
|
5
|
+
require_relative 'error'
|
|
6
|
+
|
|
7
|
+
module VerifactuRails
|
|
8
|
+
# Formateo canónico de importes monetarios.
|
|
9
|
+
#
|
|
10
|
+
# REGLA DE ORO: el string que produce este módulo es el que debe ir TANTO en la
|
|
11
|
+
# cadena de la huella COMO en el XML. Si divergen aunque sea en un decimal, la
|
|
12
|
+
# AEAT recalcula la huella sobre lo que recibe en el XML y rechaza el registro.
|
|
13
|
+
# Por eso el formateo vive aquí y en un solo sitio.
|
|
14
|
+
module Importe
|
|
15
|
+
PATRON = /\A-?\d{1,12}\.\d{2}\z/
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Normaliza a string con exactamente 2 decimales y punto como separador.
|
|
20
|
+
# Acepta BigDecimal, Integer, Rational o String; rechaza Float por
|
|
21
|
+
# imprecisión binaria (0.1 + 0.2 no es 0.3 y aquí eso es un rechazo AEAT).
|
|
22
|
+
def formatear(valor)
|
|
23
|
+
case valor
|
|
24
|
+
when Float
|
|
25
|
+
raise ValidacionError,
|
|
26
|
+
'No se admiten Float en importes: usa BigDecimal, Integer o String ' \
|
|
27
|
+
"(recibido: #{valor.inspect})"
|
|
28
|
+
when BigDecimal then decimal = valor
|
|
29
|
+
when Integer then decimal = valor.to_d
|
|
30
|
+
when Rational then decimal = valor.to_d(20)
|
|
31
|
+
when String then decimal = parsear_string(valor)
|
|
32
|
+
when nil then raise ValidacionError, 'Importe requerido'
|
|
33
|
+
else
|
|
34
|
+
raise ValidacionError, "Tipo de importe no admitido: #{valor.class}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# ROUND_HALF_UP explícito, y NO porque Ruby traiga otro por defecto: el
|
|
38
|
+
# suyo ya es HALF_UP, tanto en BigDecimal.mode como en Float#round. El
|
|
39
|
+
# motivo es que BigDecimal.mode es estado GLOBAL del proceso: cualquier
|
|
40
|
+
# gema de la aplicación puede cambiarlo, y entonces todos los importes
|
|
41
|
+
# redondearían distinto y con ellos las huellas, sin que nadie relacionara
|
|
42
|
+
# una cosa con la otra. Pasándolo en la llamada, da igual lo que haga el
|
|
43
|
+
# resto. HALF_UP es además el que aplica la normativa fiscal española.
|
|
44
|
+
resultado = decimal.round(2, BigDecimal::ROUND_HALF_UP).to_s('F')
|
|
45
|
+
resultado = format('%.2f', resultado.to_d) # asegura los 2 decimales
|
|
46
|
+
|
|
47
|
+
unless resultado.match?(PATRON)
|
|
48
|
+
raise ValidacionError, "Importe fuera de rango o mal formado: #{resultado}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# -0.00 no existe fiscalmente y rompería la comparación de huellas
|
|
52
|
+
resultado == '-0.00' ? '0.00' : resultado
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# sf:Tipo2.2Type, para los porcentajes: patrón \d{1,3}(\.\d{0,2})? — SIN
|
|
56
|
+
# signo y con tres dígitos enteros como mucho. Es un tipo distinto de
|
|
57
|
+
# sf:ImporteSgn12.2Type, así que formatear con `formatear` a secas dejaba
|
|
58
|
+
# pasar un -21.00 o un 1000.00 que luego rechaza el esquema.
|
|
59
|
+
def porcentaje(valor, campo)
|
|
60
|
+
cadena = formatear(valor)
|
|
61
|
+
unless cadena.match?(/\A\d{1,3}\.\d{2}\z/)
|
|
62
|
+
raise ValidacionError,
|
|
63
|
+
"#{campo} debe ser un porcentaje sin signo y de hasta 3 dígitos " \
|
|
64
|
+
"enteros (recibido: #{cadena})"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
cadena
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parsear_string(cadena)
|
|
71
|
+
texto = cadena.strip
|
|
72
|
+
unless texto.match?(/\A-?\d+([.,]\d+)?\z/)
|
|
73
|
+
raise ValidacionError, "Importe no numérico: #{cadena.inspect}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
texto.tr(',', '.').to_d
|
|
77
|
+
end
|
|
78
|
+
private_class_method :parsear_string
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VerifactuRails
|
|
4
|
+
module Libro
|
|
5
|
+
# Las comprobaciones que el art. 7.i) de la Orden HAC/1177/2024 obliga a hacer
|
|
6
|
+
# ANTES de generar cada registro:
|
|
7
|
+
#
|
|
8
|
+
# 1.º El último registro de facturación generado está correctamente
|
|
9
|
+
# encadenado.
|
|
10
|
+
# 2.º La fecha y hora de generación del último registro no es superior en
|
|
11
|
+
# más de un minuto a la fecha y hora actuales.
|
|
12
|
+
#
|
|
13
|
+
# DEVUELVE una lista de anomalías; no levanta excepciones. Es deliberado: las
|
|
14
|
+
# FAQs dicen que "será preciso generar el siguiente RF, ya que la facturación
|
|
15
|
+
# por este motivo NUNCA debe interrumpirse". Bloquear la caja por un problema
|
|
16
|
+
# de trazabilidad es peor remedio que la enfermedad.
|
|
17
|
+
class Autochequeo
|
|
18
|
+
# El minuto del art. 7.i).2.º.
|
|
19
|
+
MARGEN_RELOJ = 60
|
|
20
|
+
|
|
21
|
+
def initialize(anterior)
|
|
22
|
+
@anterior = anterior
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param ahora [Time] la hora con la que se va a fechar el nuevo registro
|
|
26
|
+
# @return [Array<Symbol>] vacío si todo cuadra
|
|
27
|
+
def anomalias(ahora:)
|
|
28
|
+
return [] if anterior.nil? # "salvo cuando se trate del primer registro"
|
|
29
|
+
|
|
30
|
+
fallos = []
|
|
31
|
+
fallos << :encadenamiento_roto unless eslabon_cuadra?
|
|
32
|
+
fallos << :huella_alterada unless anterior.huella_cuadra?
|
|
33
|
+
fallos << :reloj_hacia_atras if reloj_atrasado?(ahora)
|
|
34
|
+
fallos
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
attr_reader :anterior
|
|
40
|
+
|
|
41
|
+
# Lo que pide literalmente la Orden: que la huella que el RF n-1 guarda de
|
|
42
|
+
# su predecesor se corresponda con la huella del RF n-2. Un eslabón atrás,
|
|
43
|
+
# no la cadena entera.
|
|
44
|
+
def eslabon_cuadra?
|
|
45
|
+
return anterior.cadena.registros.where(huella_anterior: '').count == 1 if anterior.primero?
|
|
46
|
+
|
|
47
|
+
anterior.cadena.registros.exists?(huella: anterior.huella_anterior)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Esto va MÁS ALLÁ de lo que exige la Orden: recalcula la huella del
|
|
51
|
+
# anterior desde sus columnas y la compara con la almacenada. Es un
|
|
52
|
+
# SHA-256, y es lo único que detecta que alguien haya editado la fila en la
|
|
53
|
+
# base de datos. Ser más estricto aquí no tiene el riesgo habitual porque
|
|
54
|
+
# no bloquea nada: solo anota.
|
|
55
|
+
#
|
|
56
|
+
# (La comprobación vive en Registro#huella_cuadra?)
|
|
57
|
+
|
|
58
|
+
# OJO al sentido, que es el que confunde: que pasen horas entre registros es
|
|
59
|
+
# lo normal y no es anomalía. Lo que no se admite es fechar el nuevo
|
|
60
|
+
# registro MÁS DE UN MINUTO ANTES que el anterior, o sea que el reloj haya
|
|
61
|
+
# ido hacia atrás.
|
|
62
|
+
def reloj_atrasado?(ahora)
|
|
63
|
+
ahora < anterior.momento - MARGEN_RELOJ
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VerifactuRails
|
|
4
|
+
module Libro
|
|
5
|
+
# Una cadena de registros: un "SIF virtual" identificado por su
|
|
6
|
+
# NumeroInstalacion, con su obligado tributario.
|
|
7
|
+
#
|
|
8
|
+
# Las FAQs (v1.3) exigen que cada facturación distinta -sean de distintos
|
|
9
|
+
# obligados o del mismo obligado en centros independientes, como tiendas-
|
|
10
|
+
# lleve su propio nº de instalación, "porque se consideran SIF
|
|
11
|
+
# independientes, como si fueran SIF virtuales".
|
|
12
|
+
class Cadena < ActiveRecord::Base
|
|
13
|
+
self.table_name = 'verifactu_cadenas'
|
|
14
|
+
|
|
15
|
+
has_many :registros, class_name: 'VerifactuRails::Libro::Registro',
|
|
16
|
+
foreign_key: :cadena_id, inverse_of: :cadena, dependent: :restrict_with_error
|
|
17
|
+
belongs_to :ultimo_registro, class_name: 'VerifactuRails::Libro::Registro',
|
|
18
|
+
optional: true
|
|
19
|
+
|
|
20
|
+
validates :numero_instalacion, :nif_obligado, :nombre_obligado, presence: true
|
|
21
|
+
|
|
22
|
+
# Abre una cadena. El número de instalación se EXIGE explícito y no se
|
|
23
|
+
# genera nunca aquí: ni con un default, ni con un find_or_create_by, ni
|
|
24
|
+
# "por comodidad".
|
|
25
|
+
#
|
|
26
|
+
# No es celo: si la gema lo autogenerase, un contenedor que se recrea en
|
|
27
|
+
# cada despliegue acabaría abriendo una instalación por despliegue, y en el
|
|
28
|
+
# límite una por factura, que es exactamente el patrón que vacía de sentido
|
|
29
|
+
# el encadenamiento. Abrir una instalación es un acto deliberado de quien
|
|
30
|
+
# despliega y tiene que constar como tal.
|
|
31
|
+
#
|
|
32
|
+
# Las FAQs recomiendan como valor un timestamp de instalación o un
|
|
33
|
+
# secuencial propio del obligado. No puede repetirse NUNCA, ni al reinstalar
|
|
34
|
+
# el mismo software en la misma máquina.
|
|
35
|
+
def self.abrir!(numero_instalacion:, nif_obligado:, nombre_obligado:)
|
|
36
|
+
if numero_instalacion.to_s.strip.empty?
|
|
37
|
+
raise ValidacionError,
|
|
38
|
+
'numero_instalacion es obligatorio y no se genera solo. Usa un ' \
|
|
39
|
+
'timestamp de instalación o un secuencial propio del obligado, y ' \
|
|
40
|
+
'no lo reutilices jamás (FAQs Desarrolladores v1.3).'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
create!(numero_instalacion: numero_instalacion,
|
|
44
|
+
nif_obligado: Formato.nif(nif_obligado, 'NIF del obligado'),
|
|
45
|
+
nombre_obligado: Formato.limitar(nombre_obligado, 'NombreRazon del obligado', 120))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Anota un alta y la encadena. Síncrono y bajo lock: el encadenamiento no
|
|
49
|
+
# se puede diferir, aunque el ENVÍO sí.
|
|
50
|
+
def anotar_alta!(**datos)
|
|
51
|
+
anotar!(RegistroAlta, 'alta', **datos)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def anotar_anulacion!(**datos)
|
|
55
|
+
anotar!(RegistroAnulacion, 'anulacion', **datos)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def sistema_informatico = Libro.configuracion.sistema_para(self)
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def anotar!(clase, tipo, **datos)
|
|
63
|
+
with_lock do # SELECT ... FOR UPDATE sobre esta fila
|
|
64
|
+
previo = ultimo_registro
|
|
65
|
+
|
|
66
|
+
# Art. 7.i) de la OM. NO levanta excepción a propósito: ante una
|
|
67
|
+
# anomalía de trazabilidad "la facturación NUNCA debe interrumpirse".
|
|
68
|
+
anomalias = Autochequeo.new(previo).anomalias(ahora: Time.now)
|
|
69
|
+
|
|
70
|
+
# Esto SÍ puede levantar: con datos inválidos no hay registro que
|
|
71
|
+
# generar, y tampoco hay factura que emitir.
|
|
72
|
+
registro = clase.new(**datos, sistema_informatico: sistema_informatico)
|
|
73
|
+
|
|
74
|
+
anterior = previo&.a_registro_anterior
|
|
75
|
+
fila = registros.create!(
|
|
76
|
+
**columnas(registro, tipo),
|
|
77
|
+
huella: registro.huella(anterior: anterior),
|
|
78
|
+
huella_anterior: previo&.huella || '',
|
|
79
|
+
payload: Libro.fragmento_xml(registro, anterior),
|
|
80
|
+
qr_url: qr_de(registro, tipo),
|
|
81
|
+
anomalias: anomalias.any? ? JSON.generate(anomalias) : nil
|
|
82
|
+
)
|
|
83
|
+
update!(ultimo_registro_id: fila.id)
|
|
84
|
+
Libro.avisar_de(anomalias, fila) if anomalias.any?
|
|
85
|
+
fila
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def columnas(registro, tipo)
|
|
90
|
+
comunes = { tipo: tipo, id_emisor: registro.id_emisor,
|
|
91
|
+
num_serie: registro.num_serie,
|
|
92
|
+
fecha_expedicion: registro.fecha_expedicion,
|
|
93
|
+
fecha_hora_gen: registro.fecha_hora_gen }
|
|
94
|
+
return comunes if tipo == 'anulacion'
|
|
95
|
+
|
|
96
|
+
comunes.merge(tipo_factura: registro.tipo_factura,
|
|
97
|
+
cuota_total: registro.cuota_total,
|
|
98
|
+
importe_total: registro.importe_total)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Una anulación no lleva QR propio: el QR va en la factura, y una anulación
|
|
102
|
+
# no expide factura. Se guarda vacío para no dejar la columna nula.
|
|
103
|
+
def qr_de(registro, tipo)
|
|
104
|
+
return '' if tipo == 'anulacion'
|
|
105
|
+
|
|
106
|
+
QR.url(registro, entorno: Libro.configuracion.entorno)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VerifactuRails
|
|
4
|
+
module Libro
|
|
5
|
+
# El esquema del libro registro, en una migración reutilizable: la usa el
|
|
6
|
+
# generador de Rails y también la suite de tests, para que lo que se prueba
|
|
7
|
+
# sea exactamente lo que se instala.
|
|
8
|
+
#
|
|
9
|
+
# ESTA CLASE ESTÁ CONGELADA. Es el esquema v1 y no se toca nunca más. La
|
|
10
|
+
# migración que `rails g verifactu:install` deja en la app no copia el
|
|
11
|
+
# esquema: hereda de aquí. Eso significa que cambiarlo mutaría el pasado —
|
|
12
|
+
# una app que ya migró se quedaría con el esquema viejo mientras una
|
|
13
|
+
# instalación nueva estrena el nuevo, las dos convencidas de estar al día.
|
|
14
|
+
# Los cambios de esquema van en migraciones NUEVAS y aparte.
|
|
15
|
+
#
|
|
16
|
+
# Portable a PostgreSQL y MySQL: nada de índices parciales, de jsonb ni de
|
|
17
|
+
# tipos propios de un motor. Lo único que se exige de la base de datos es que
|
|
18
|
+
# sepa bloquear filas (SELECT ... FOR UPDATE) y respetar índices únicos.
|
|
19
|
+
class Migracion < ActiveRecord::Migration[7.0]
|
|
20
|
+
def change
|
|
21
|
+
create_table :verifactu_cadenas do |t|
|
|
22
|
+
# Una cadena por "SIF virtual". No lleva valor por defecto NI se genera
|
|
23
|
+
# sola en ninguna parte: abrir una instalación es un acto deliberado de
|
|
24
|
+
# quien despliega (ver doc/FUENTES.md).
|
|
25
|
+
t.string :numero_instalacion, null: false, limit: 100
|
|
26
|
+
t.string :nif_obligado, null: false, limit: 9
|
|
27
|
+
t.string :nombre_obligado, null: false, limit: 120
|
|
28
|
+
t.bigint :ultimo_registro_id
|
|
29
|
+
t.datetime :no_enviar_antes_de
|
|
30
|
+
t.timestamps
|
|
31
|
+
|
|
32
|
+
t.index :numero_instalacion, unique: true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
create_table :verifactu_registros do |t|
|
|
36
|
+
t.references :cadena, null: false, index: false,
|
|
37
|
+
foreign_key: { to_table: :verifactu_cadenas }
|
|
38
|
+
t.string :tipo, null: false, limit: 10 # alta | anulacion
|
|
39
|
+
|
|
40
|
+
# Las ENTRADAS de la huella van como columnas, no enterradas en el
|
|
41
|
+
# payload: son lo que permite recalcularla años después sin parsear
|
|
42
|
+
# nada. Se guardan ya formateadas, con el mismo string que se serializó.
|
|
43
|
+
t.string :id_emisor, null: false, limit: 9
|
|
44
|
+
t.string :num_serie, null: false, limit: 60
|
|
45
|
+
t.string :fecha_expedicion, null: false, limit: 10 # dd-mm-yyyy, canónico
|
|
46
|
+
t.string :tipo_factura, limit: 2 # null en las anulaciones
|
|
47
|
+
t.string :cuota_total, limit: 20
|
|
48
|
+
t.string :importe_total, limit: 20
|
|
49
|
+
t.string :huella, null: false, limit: 64
|
|
50
|
+
|
|
51
|
+
# Centinela '' para el primer eslabón, NO null: en Postgres y en MySQL
|
|
52
|
+
# dos NULL no colisionan en un índice único, así que con null se podría
|
|
53
|
+
# colar más de un PrimerRegistro por cadena. Con '' no.
|
|
54
|
+
t.string :huella_anterior, null: false, limit: 64, default: ''
|
|
55
|
+
|
|
56
|
+
# El STRING exacto que entró en la huella, con su huso. Una columna
|
|
57
|
+
# datetime normaliza a UTC y pierde el offset, y entonces la huella ya
|
|
58
|
+
# no se puede recalcular.
|
|
59
|
+
t.string :fecha_hora_gen, null: false, limit: 25
|
|
60
|
+
|
|
61
|
+
# El fragmento XML del registro, ya construido y con su huella dentro.
|
|
62
|
+
# Se guarda hecho, y no los argumentos con que se construyó, para que
|
|
63
|
+
# lo que se envíe sea exactamente lo que se calculó: la huella y el XML
|
|
64
|
+
# no pueden divergir si nadie los vuelve a derivar.
|
|
65
|
+
t.text :payload, null: false
|
|
66
|
+
t.integer :payload_version, null: false, default: 1
|
|
67
|
+
t.text :qr_url, null: false
|
|
68
|
+
t.text :anomalias # JSON con los fallos del art. 7.i, o null
|
|
69
|
+
t.string :estado, null: false, limit: 20, default: 'pendiente'
|
|
70
|
+
t.string :csv, limit: 20
|
|
71
|
+
t.integer :codigo_error
|
|
72
|
+
t.text :descripcion_error
|
|
73
|
+
t.datetime :enviado_at
|
|
74
|
+
t.timestamps
|
|
75
|
+
|
|
76
|
+
# ESTE es el índice que impide bifurcar la cadena. Dos registros no
|
|
77
|
+
# pueden compartir predecesor, pase lo que pase con el lock. Está
|
|
78
|
+
# comprobado que la AEAT acepta una cadena bifurcada sin avisar, así
|
|
79
|
+
# que esta restricción es la única red que hay.
|
|
80
|
+
t.index %i[cadena_id huella_anterior], unique: true,
|
|
81
|
+
name: 'idx_verifactu_sin_bifurcacion'
|
|
82
|
+
t.index %i[cadena_id huella], unique: true
|
|
83
|
+
t.index %i[cadena_id estado id]
|
|
84
|
+
|
|
85
|
+
# NO es único a propósito: una subsanación reutiliza el mismo IDFactura
|
|
86
|
+
# deliberadamente, y así lo acepta la AEAT (comprobado).
|
|
87
|
+
t.index %i[id_emisor num_serie fecha_expedicion], name: 'idx_verifactu_id_factura'
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|