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