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,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