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,396 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date' # VENTANAS_TIPO y RECARGOS_POR_TIPO construyen Date al cargar
4
+ require_relative 'formato'
5
+ require_relative 'importe'
6
+ require_relative 'error'
7
+
8
+ module VerifactuRails
9
+ # Una línea del desglose de la factura (sf:DetalleType).
10
+ #
11
+ # Todos los importes pasan por Importe.formatear: el string que sale de aquí es
12
+ # el mismo que la AEAT usará para recalcular la huella. No formatear por otro
13
+ # lado, nunca.
14
+ class Detalle
15
+ IMPUESTOS = %w[01 02 03 05].freeze # 01 IVA, 02 IPSI, 03 IGIC, 05 Otros
16
+ CALIFICACIONES = %w[S1 S2 N1 N2].freeze
17
+
18
+ # Lista L10. E7 y E8 NO son valores generales: solo se admiten con IGIC
19
+ # (Validaciones v1.2.2, ap. 15.5).
20
+ EXENCIONES = %w[E1 E2 E3 E4 E5 E6].freeze
21
+ EXENCIONES_IGIC = (EXENCIONES + %w[E7 E8]).freeze
22
+
23
+ # ClaveRegimen admisible por impuesto: L8A para IVA, L8B (+20) para IGIC y el
24
+ # subconjunto del ap. 15.6 para IPSI. Con Impuesto = 05 (Otros) el campo no
25
+ # se puede informar en absoluto.
26
+ REGIMENES_IVA = %w[01 02 03 04 05 06 07 08 09 10 11 14 15 17 18 19 20].freeze
27
+ REGIMENES_IGIC = (REGIMENES_IVA + %w[21]).freeze
28
+ REGIMENES_IPSI = %w[01 08 11 18 19 20].freeze
29
+ REGIMENES = (REGIMENES_IGIC | REGIMENES_IVA).freeze
30
+
31
+ # Ap. 15.1 y 15.3, para IVA con operación sujeta y no exenta.
32
+ TIPOS_IMPOSITIVOS_IVA = %w[0.00 2.00 4.00 5.00 7.50 10.00 21.00].freeze
33
+ RECARGOS_IVA = %w[0.00 0.26 0.50 0.62 1.00 1.40 1.75 5.20].freeze
34
+
35
+ # Ap. 15.1. Tres de los tipos NO son de aplicación permanente: fueron
36
+ # rebajas temporales y solo se admiten si la fecha de referencia cae dentro
37
+ # de su ventana. Los demás no llevan ventana.
38
+ #
39
+ # Consecuencia práctica que sorprende: como FechaExpedicionFactura no puede
40
+ # ser anterior al 28-10-2024, el 5 % ya NO es declarable salvo que se informe
41
+ # una FechaOperacion dentro de su ventana. Es el origen de los errores 1235 y
42
+ # 1236 de la AEAT.
43
+ VENTANAS_TIPO = {
44
+ '5.00' => [Date.new(2022, 7, 1), Date.new(2024, 9, 30)],
45
+ '2.00' => [Date.new(2024, 10, 1), Date.new(2024, 12, 31)],
46
+ '7.50' => [Date.new(2024, 10, 1), Date.new(2024, 12, 31)]
47
+ }.freeze
48
+
49
+ # Ap. 15.3. Qué recargo de equivalencia admite cada tipo impositivo. Cada
50
+ # entrada es [ventana, recargos]; ventana nil significa "siempre".
51
+ #
52
+ # Solo se exige lo que la norma afirma. Para el 0 % y el 5 % fuera de sus
53
+ # ventanas el texto no dice nada, y ahí NO se inventa una restricción: ser
54
+ # más estricto que la norma ya bloqueó casos válidos antes en esta gema.
55
+ RECARGOS_POR_TIPO = {
56
+ '21.00' => [[nil, %w[5.20 1.75]]],
57
+ '10.00' => [[nil, %w[1.40]]],
58
+ '7.50' => [[nil, %w[1.00]]],
59
+ '4.00' => [[nil, %w[0.50]]],
60
+ '2.00' => [[nil, %w[0.26]]],
61
+ '5.00' => [[[nil, Date.new(2022, 12, 31)], %w[0.50]],
62
+ [[Date.new(2023, 1, 1), Date.new(2024, 9, 30)], %w[0.62]]],
63
+ '0.00' => [[[Date.new(2023, 1, 1), Date.new(2024, 9, 30)], %w[0.00]]]
64
+ }.freeze
65
+
66
+ # Campos que solo tienen sentido en una operación sujeta y no exenta.
67
+ CAMPOS_DE_SUJECION = %i[tipo_impositivo cuota_repercutida
68
+ tipo_recargo cuota_recargo].freeze
69
+
70
+ attr_reader :base_imponible, :calificacion, :exenta, :tipo_impositivo,
71
+ :cuota_repercutida, :impuesto, :clave_regimen,
72
+ :tipo_recargo, :cuota_recargo
73
+
74
+ # calificacion y exenta son excluyentes: el XSD las modela como <choice>.
75
+ def initialize(base_imponible:, calificacion: nil, exenta: nil,
76
+ tipo_impositivo: nil, cuota_repercutida: nil,
77
+ impuesto: '01', clave_regimen: '01',
78
+ tipo_recargo: nil, cuota_recargo: nil)
79
+ if calificacion.nil? == exenta.nil?
80
+ raise ValidacionError,
81
+ 'Indica exactamente una de calificacion: o exenta: ' \
82
+ '(el esquema las modela como choice, no caben las dos ni ninguna)'
83
+ end
84
+
85
+ @base_imponible = Importe.formatear(base_imponible)
86
+ @calificacion = calificacion && Formato.enumerado(calificacion, 'CalificacionOperacion', CALIFICACIONES)
87
+ @impuesto = impuesto && Formato.enumerado(impuesto, 'Impuesto', IMPUESTOS)
88
+ @exenta = exenta && Formato.enumerado(exenta, 'OperacionExenta', exenciones_admitidas)
89
+ # Sin normalizar todavía: con Impuesto=05 no cabe ninguna clave, y un
90
+ # enumerado de lista vacía daría "Admitidos: " en lugar del motivo real.
91
+ # validar_clave_regimen! resuelve primero ese caso.
92
+ @clave_regimen = clave_regimen&.to_s
93
+ @tipo_impositivo = tipo_impositivo && Importe.porcentaje(tipo_impositivo, 'TipoImpositivo')
94
+ @cuota_repercutida = cuota_repercutida && Importe.formatear(cuota_repercutida)
95
+ @tipo_recargo = tipo_recargo && Importe.porcentaje(tipo_recargo, 'TipoRecargoEquivalencia')
96
+ @cuota_recargo = cuota_recargo && Importe.formatear(cuota_recargo)
97
+
98
+ validar_coherencia!
99
+ end
100
+
101
+ def exenta? = !@exenta.nil?
102
+
103
+ # Reglas del ap. 15.1 y 15.3 que dependen de la fecha de la operación, que
104
+ # esta clase no conoce: la aporta RegistroAlta, que es quien sabe si hay
105
+ # FechaOperacion o hay que caer en FechaExpedicionFactura. Se deja aquí y no
106
+ # allí para que la tabla viva junto al resto de reglas del desglose.
107
+ #
108
+ # Ambas cuelgan de "Impuesto = 01 (IVA) y CalificacionOperacion = S1".
109
+ def validar_en_fecha!(fecha)
110
+ return unless iva? && calificacion == 'S1'
111
+
112
+ validar_ventana_tipo!(fecha)
113
+ validar_recargo_en_fecha!(fecha)
114
+ end
115
+
116
+ # Orden EXACTO de sf:DetalleType (<sequence>).
117
+ def a_pares
118
+ pares = {}
119
+ pares['Impuesto'] = impuesto if impuesto
120
+ pares['ClaveRegimen'] = clave_regimen if clave_regimen
121
+ if exenta?
122
+ pares['OperacionExenta'] = exenta
123
+ else
124
+ pares['CalificacionOperacion'] = calificacion
125
+ end
126
+ pares['TipoImpositivo'] = tipo_impositivo if tipo_impositivo
127
+ pares['BaseImponibleOimporteNoSujeto'] = base_imponible
128
+ pares['CuotaRepercutida'] = cuota_repercutida if cuota_repercutida
129
+ pares['TipoRecargoEquivalencia'] = tipo_recargo if tipo_recargo
130
+ pares['CuotaRecargoEquivalencia'] = cuota_recargo if cuota_recargo
131
+ pares
132
+ end
133
+
134
+ def iva? = impuesto.nil? || impuesto == '01' # si no se informa, se asume IVA
135
+
136
+ private
137
+
138
+ def exenciones_admitidas = impuesto == '03' ? EXENCIONES_IGIC : EXENCIONES
139
+
140
+ # Ap. 15.6. Con Impuesto = 05 (Otros) el campo no se puede informar; ahí la
141
+ # lista vacía hace que Formato.enumerado rechace cualquier valor.
142
+ def regimenes_admitidos
143
+ case impuesto
144
+ when '02' then REGIMENES_IPSI
145
+ when '03' then REGIMENES_IGIC
146
+ when '05' then []
147
+ else REGIMENES_IVA
148
+ end
149
+ end
150
+
151
+ def presentes(campos) = campos.select { |c| !send(c).nil? }
152
+
153
+ def validar_coherencia!
154
+ validar_clave_regimen!
155
+ validar_regimen_de_la_linea!
156
+ validar_exenta!
157
+ validar_calificacion!
158
+ validar_recargo!
159
+ end
160
+
161
+ # Ap. 15.6.1 a 15.6.11: cada clave de régimen restringe qué calificación o
162
+ # qué exención caben en SU MISMA línea. Las que además miran el tipo de
163
+ # factura, los destinatarios o la fecha —las claves 06, 10 y 14— no están
164
+ # aquí: las dispara RegistroAlta, que es quien conoce esos datos.
165
+ #
166
+ # Solo aplican con IVA e IGIC, y eso no es un descuido: la norma acota cada
167
+ # regla a "Impuesto = 01, 03 o no se cumplimenta". En IPSI las claves 18, 19
168
+ # y 20 significan otra cosa —art. 73 de la Ordenanza de Ceuta, exentas
169
+ # interiores y estimación objetiva—, así que aplicarles estas restricciones
170
+ # sería inventárselas sobre una lista distinta.
171
+ def validar_regimen_de_la_linea!
172
+ return unless iva? || impuesto == '03'
173
+
174
+ case clave_regimen
175
+ when '02' then validar_regimen_exportacion!
176
+ when '03' then validar_regimen_rebu!
177
+ when '04' then validar_regimen_oro!
178
+ when '07' then validar_regimen_criterio_caja!
179
+ when '08' then exigir_calificacion!('N2', '08 (operaciones sujetas a IPSI/IGIC)')
180
+ when '11' then validar_regimen_arrendamiento!
181
+ when '20' then exigir_calificacion!('N2', '20 con IGIC (sujetas al IPSI)') if impuesto == '03'
182
+ end
183
+ end
184
+
185
+ # Ap. 15.6: solo cabe con IVA, IPSI o IGIC, y ahí es obligatorio.
186
+ def validar_clave_regimen!
187
+ if impuesto == '05'
188
+ return if clave_regimen.nil?
189
+
190
+ raise ValidacionError, 'ClaveRegimen no se puede informar con Impuesto=05 (Otros)'
191
+ end
192
+ if clave_regimen.nil?
193
+ raise ValidacionError, "ClaveRegimen es obligatorio con Impuesto=#{impuesto || '01'}"
194
+ end
195
+
196
+ @clave_regimen = Formato.enumerado(clave_regimen, 'ClaveRegimen', regimenes_admitidos)
197
+ end
198
+
199
+ # Ap. 15.6.1. "Solo puede estar cumplimentado OperacionExenta", o sea que la
200
+ # línea tiene que ser exenta.
201
+ def validar_regimen_exportacion!
202
+ return if exenta?
203
+
204
+ raise ValidacionError,
205
+ 'ClaveRegimen 02 (exportación) solo admite OperacionExenta, no ' \
206
+ "CalificacionOperacion #{calificacion}"
207
+ end
208
+
209
+ # Ap. 15.6.2. Ojo al matiz: la calificación es OPCIONAL aquí -el REBU admite
210
+ # operación exenta, y la propia norma lo aclara citando el art. 137.Dos.5ª de
211
+ # la Ley 37/1992-, pero si se informa solo cabe S1.
212
+ def validar_regimen_rebu!
213
+ return if calificacion.nil? || calificacion == 'S1'
214
+
215
+ raise ValidacionError,
216
+ 'ClaveRegimen 03 (REBU) solo admite CalificacionOperacion S1, no ' \
217
+ "#{calificacion}"
218
+ end
219
+
220
+ # Ap. 15.6.3: "solo puede ser S2, o bien OperacionExenta".
221
+ def validar_regimen_oro!
222
+ return if exenta? || calificacion == 'S2'
223
+
224
+ raise ValidacionError,
225
+ 'ClaveRegimen 04 (oro de inversión) exige CalificacionOperacion S2 ' \
226
+ 'o una operación exenta'
227
+ end
228
+
229
+ # Ap. 15.6.5, que va por los dos lados: ni ciertas calificaciones ni ciertas
230
+ # exenciones.
231
+ def validar_regimen_criterio_caja!
232
+ if %w[S2 N1 N2].include?(calificacion)
233
+ raise ValidacionError,
234
+ 'ClaveRegimen 07 (criterio de caja) no admite CalificacionOperacion ' \
235
+ "#{calificacion}"
236
+ end
237
+ return unless %w[E2 E3 E4 E5].include?(exenta)
238
+
239
+ raise ValidacionError,
240
+ "ClaveRegimen 07 (criterio de caja) no admite OperacionExenta #{exenta}"
241
+ end
242
+
243
+ # Ap. 15.6.8, y solo para IVA: "únicamente se admitirá el TipoImpositivo = 21".
244
+ #
245
+ # Si no hay tipo impositivo no se exige ninguno: la norma restringe cuál cabe,
246
+ # no obliga a informarlo. Inventarse esa obligación bloquearía un arrendamiento
247
+ # exento, que es el error que esta gema ya cometió una vez con FacturasRectificadas.
248
+ def validar_regimen_arrendamiento!
249
+ return unless iva?
250
+ return if tipo_impositivo.nil? || tipo_impositivo == '21.00'
251
+
252
+ raise ValidacionError,
253
+ 'ClaveRegimen 11 (arrendamiento de local de negocio) solo admite ' \
254
+ "TipoImpositivo 21.00, no #{tipo_impositivo}"
255
+ end
256
+
257
+ # Ap. 15.6.6 y 15.6.10: "tiene que ser NN y siempre debe ir relleno", así que
258
+ # una línea exenta tampoco vale.
259
+ def exigir_calificacion!(esperada, descripcion)
260
+ return if calificacion == esperada
261
+
262
+ tenia = exenta? ? "OperacionExenta #{exenta}" : "CalificacionOperacion #{calificacion.inspect}"
263
+ raise ValidacionError,
264
+ "ClaveRegimen #{descripcion} exige CalificacionOperacion #{esperada}, y llegó #{tenia}"
265
+ end
266
+
267
+ # Ap. 15.5: una exenta no admite ninguno de los campos de sujeción.
268
+ def validar_exenta!
269
+ return unless exenta?
270
+
271
+ sobran = presentes(CAMPOS_DE_SUJECION)
272
+ unless sobran.empty?
273
+ raise ValidacionError,
274
+ "Una operación exenta (#{exenta}) no admite #{sobran.join(', ')}"
275
+ end
276
+ return unless %w[E2 E3].include?(exenta) && clave_regimen == '01' && (iva? || impuesto == '03')
277
+
278
+ raise ValidacionError,
279
+ "OperacionExenta #{exenta} no cabe con ClaveRegimen=01"
280
+ end
281
+
282
+ # Ap. 15.4 y 15.7.
283
+ def validar_calificacion!
284
+ case calificacion
285
+ when 'S1' then validar_sujeta_no_exenta!
286
+ when 'S2'
287
+ # Inversión del sujeto pasivo: la cuota la repercute el destinatario.
288
+ unless tipo_impositivo == '0.00' && cuota_repercutida == '0.00'
289
+ raise ValidacionError,
290
+ 'CalificacionOperacion S2 (inversión del sujeto pasivo) exige ' \
291
+ 'tipo_impositivo y cuota_repercutida a cero, ambos informados'
292
+ end
293
+ when 'N1', 'N2'
294
+ return unless iva?
295
+
296
+ sobran = presentes(CAMPOS_DE_SUJECION)
297
+ return if sobran.empty?
298
+
299
+ raise ValidacionError,
300
+ "Una operación no sujeta (#{calificacion}) no admite #{sobran.join(', ')}"
301
+ end
302
+ end
303
+
304
+ def validar_sujeta_no_exenta!
305
+ if tipo_impositivo.nil? || cuota_repercutida.nil?
306
+ raise ValidacionError,
307
+ 'CalificacionOperacion S1 exige tipo_impositivo y cuota_repercutida'
308
+ end
309
+ return unless iva? && !TIPOS_IMPOSITIVOS_IVA.include?(tipo_impositivo)
310
+
311
+ raise ValidacionError,
312
+ "TipoImpositivo #{tipo_impositivo} no existe en IVA. " \
313
+ "Admitidos: #{TIPOS_IMPOSITIVOS_IVA.join(', ')}"
314
+ end
315
+
316
+ def validar_ventana_tipo!(fecha)
317
+ desde, hasta = VENTANAS_TIPO[tipo_impositivo]
318
+ return if desde.nil? || fecha.between?(desde, hasta)
319
+
320
+ raise ValidacionError,
321
+ "TipoImpositivo #{tipo_impositivo} solo se admite entre " \
322
+ "#{desde.strftime('%d-%m-%Y')} y #{hasta.strftime('%d-%m-%Y')} " \
323
+ "(fecha de referencia: #{fecha.strftime('%d-%m-%Y')}). Fue una rebaja " \
324
+ 'temporal; si la operación es de aquellas fechas, informa fecha_operacion.'
325
+ end
326
+
327
+ def validar_recargo_en_fecha!(fecha)
328
+ return if tipo_recargo.nil?
329
+
330
+ tramos = RECARGOS_POR_TIPO[tipo_impositivo] || []
331
+ tramo = tramos.find { |ventana, _| en_ventana?(ventana, fecha) }
332
+ return if tramo.nil? || tramo[1].include?(tipo_recargo)
333
+
334
+ raise ValidacionError,
335
+ "Con TipoImpositivo #{tipo_impositivo}, el recargo de equivalencia solo " \
336
+ "puede ser #{tramo[1].join(' o ')} (recibido #{tipo_recargo})"
337
+ end
338
+
339
+ def en_ventana?(ventana, fecha)
340
+ return true if ventana.nil?
341
+
342
+ desde, hasta = ventana
343
+ (desde.nil? || fecha >= desde) && (hasta.nil? || fecha <= hasta)
344
+ end
345
+
346
+ def validar_recargo!
347
+ if cuota_recargo && tipo_recargo.nil?
348
+ raise ValidacionError, 'CuotaRecargoEquivalencia exige TipoRecargoEquivalencia'
349
+ end
350
+ return unless tipo_recargo && iva? && calificacion == 'S1'
351
+ return if RECARGOS_IVA.include?(tipo_recargo)
352
+
353
+ raise ValidacionError,
354
+ "TipoRecargoEquivalencia #{tipo_recargo} no existe en IVA. " \
355
+ "Admitidos: #{RECARGOS_IVA.join(', ')}"
356
+ end
357
+ end
358
+
359
+ # Conjunto de líneas del desglose (sf:DesgloseType).
360
+ class Desglose
361
+ MAXIMO_LINEAS = 12 # maxOccurs del esquema; no es orientativo
362
+
363
+ attr_reader :detalles
364
+
365
+ def initialize(detalles)
366
+ @detalles = Array(detalles)
367
+
368
+ raise ValidacionError, 'El desglose necesita al menos una línea' if @detalles.empty?
369
+
370
+ if @detalles.size > MAXIMO_LINEAS
371
+ raise ValidacionError,
372
+ "El desglose admite como mucho #{MAXIMO_LINEAS} líneas " \
373
+ "(recibidas #{@detalles.size}). Agrupa por tipo impositivo."
374
+ end
375
+
376
+ unless @detalles.all?(Detalle)
377
+ raise ValidacionError, 'Todas las líneas deben ser VerifactuRails::Detalle'
378
+ end
379
+ end
380
+
381
+ # Suma de cuotas repercutidas y de recargos. Se ofrece como ayuda para
382
+ # rellenar CuotaTotal, pero NO se impone: la relación entre el desglose y los
383
+ # totales tiene casos límite (rectificativas, no sujetas) y preferimos que el
384
+ # importe que se envía sea siempre el que decide quien llama.
385
+ def cuota_total
386
+ suma = detalles.sum do |d|
387
+ BigDecimal(d.cuota_repercutida || '0') + BigDecimal(d.cuota_recargo || '0')
388
+ end
389
+ Importe.formatear(suma)
390
+ end
391
+
392
+ def base_total
393
+ Importe.formatear(detalles.sum { |d| BigDecimal(d.base_imponible) })
394
+ end
395
+ end
396
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+ require_relative 'formato'
5
+ require_relative 'registro'
6
+ require_relative 'error'
7
+
8
+ module VerifactuRails
9
+ # Documento RegFactuSistemaFacturacion: la cabecera con el obligado tributario
10
+ # más un lote de registros de facturación.
11
+ #
12
+ # El encadenamiento es estrictamente serial y esta clase NO lo gestiona: recibe
13
+ # los registros ya emparejados con su registro anterior. Decidir cuál es el
14
+ # anterior exige un lock por SIF+NIF en la base de datos, y eso pertenece a la
15
+ # capa de integración, no a un generador de XML.
16
+ class Envio
17
+ MAXIMO_REGISTROS = 1000 # maxOccurs de RegistroFactura en el esquema
18
+
19
+ attr_reader :nif_obligado, :nombre_obligado, :entradas
20
+
21
+ # @param entradas [Array<Array(registro, anterior)>] cada registro con el
22
+ # RegistroAnterior que le precede en la cadena (nil solo en el primero).
23
+ def initialize(nif_obligado:, nombre_obligado:, entradas:)
24
+ @nif_obligado = Formato.nif(nif_obligado, 'NIF del obligado')
25
+ @nombre_obligado = Formato.limitar(nombre_obligado, 'NombreRazon del obligado', 120)
26
+ @entradas = normalizar(entradas)
27
+
28
+ raise ValidacionError, 'El envío necesita al menos un registro' if @entradas.empty?
29
+
30
+ if entradas.size > MAXIMO_REGISTROS
31
+ raise ValidacionError,
32
+ "Un envío admite como mucho #{MAXIMO_REGISTROS} registros " \
33
+ "(recibidos #{entradas.size}). Parte el lote."
34
+ end
35
+
36
+ validar_emisores!
37
+ end
38
+
39
+ # Envío a partir de fragmentos XML ya construidos, en vez de a partir de los
40
+ # objetos.
41
+ #
42
+ # Es lo que usa la capa de persistencia: cada registro guardó su XML en el
43
+ # momento de anotarse, con su huella dentro, y al enviar se agrupan tal cual.
44
+ # Así lo que viaja es LITERALMENTE lo que se calculó bajo el lock, sin volver
45
+ # a derivar nada. Reconstruir el XML al enviar abriría la puerta a que la
46
+ # huella almacenada y la enviada divergieran, que es el peor fallo posible
47
+ # aquí: la AEAT recalcula sobre lo que recibe.
48
+ #
49
+ # @param fragmentos [Array<String>] elementos sum:RegistroFactura
50
+ def self.xml_desde_fragmentos(nif_obligado:, nombre_obligado:, fragmentos:)
51
+ if fragmentos.empty?
52
+ raise ValidacionError, 'El envío necesita al menos un registro'
53
+ end
54
+ if fragmentos.size > MAXIMO_REGISTROS
55
+ raise ValidacionError,
56
+ "Un envío admite como mucho #{MAXIMO_REGISTROS} registros " \
57
+ "(recibidos #{fragmentos.size}). Parte el lote."
58
+ end
59
+
60
+ nif = Formato.nif(nif_obligado, 'NIF del obligado')
61
+ nombre = Formato.limitar(nombre_obligado, 'NombreRazon del obligado', 120)
62
+
63
+ constructor = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
64
+ xml['sum'].RegFactuSistemaFacturacion('xmlns:sum' => NS_LR, 'xmlns:sum1' => NS_SF) do
65
+ xml['sum'].Cabecera do
66
+ xml['sum1'].ObligadoEmision do
67
+ xml['sum1'].NombreRazon nombre
68
+ xml['sum1'].NIF nif
69
+ end
70
+ end
71
+ # << inserta el markup tal cual, sin reinterpretarlo.
72
+ fragmentos.each { |fragmento| xml << fragmento }
73
+ end
74
+ end
75
+ constructor.to_xml
76
+ end
77
+
78
+ def to_xml
79
+ constructor = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
80
+ xml['sum'].RegFactuSistemaFacturacion('xmlns:sum' => NS_LR, 'xmlns:sum1' => NS_SF) do
81
+ xml['sum'].Cabecera do
82
+ xml['sum1'].ObligadoEmision do
83
+ xml['sum1'].NombreRazon nombre_obligado
84
+ xml['sum1'].NIF nif_obligado
85
+ end
86
+ end
87
+ entradas.each do |registro, anterior|
88
+ xml['sum'].RegistroFactura do
89
+ registro.construir(xml, anterior: anterior)
90
+ end
91
+ end
92
+ end
93
+ end
94
+ constructor.to_xml
95
+ end
96
+
97
+ private
98
+
99
+ # `entradas` es una lista de pares [registro, anterior]. Sin comprobarla:
100
+ # - [[alta]] desestructura con anterior=nil y el registro sale como
101
+ # PrimerRegistro="S" en silencio, que es un error admisible en la AEAT si
102
+ # ya hay registros de ese SIF.
103
+ # - una anulación con anterior=nil se construía y solo fallaba al
104
+ # serializar, pese a que RegistroAnulacion dice explícitamente que quiere
105
+ # fallar antes. El sitio donde se conoce el anterior es este.
106
+ def normalizar(entradas)
107
+ unless entradas.is_a?(Array)
108
+ raise ValidacionError, "entradas debe ser un array de pares [registro, anterior] " \
109
+ "(recibido: #{entradas.class})"
110
+ end
111
+
112
+ entradas.each_with_index.map do |entrada, i|
113
+ unless entrada.is_a?(Array) && entrada.size == 2
114
+ raise ValidacionError,
115
+ "entradas[#{i}] debe ser un par [registro, anterior]; para el primero " \
116
+ 'de la cadena, [registro, nil]'
117
+ end
118
+
119
+ registro, anterior = entrada
120
+ unless registro.is_a?(RegistroAlta) || registro.is_a?(RegistroAnulacion)
121
+ raise ValidacionError,
122
+ "entradas[#{i}] debe llevar un RegistroAlta o RegistroAnulacion " \
123
+ "(recibido: #{registro.class})"
124
+ end
125
+ unless anterior.nil? || anterior.is_a?(RegistroAnterior)
126
+ raise ValidacionError,
127
+ "entradas[#{i}]: el anterior debe ser RegistroAnterior o nil " \
128
+ "(recibido: #{anterior.class})"
129
+ end
130
+ if anterior.nil? && registro.is_a?(RegistroAnulacion)
131
+ raise ValidacionError,
132
+ "entradas[#{i}]: una anulación no puede iniciar la cadena, exige " \
133
+ 'el registro anterior'
134
+ end
135
+
136
+ entrada
137
+ end
138
+ end
139
+
140
+ # El emisor de cada registro tiene que ser el obligado de la cabecera
141
+ # (Validaciones v1.2.2, ap. 3.1.3.1 y 3.1.4.1). Es una comprobación cruzada
142
+ # entre cabecera y registros, así que solo se puede hacer aquí: un registro
143
+ # aislado no sabe en qué envío acabará.
144
+ def validar_emisores!
145
+ intrusos = entradas.map(&:first).map(&:id_emisor).uniq.reject { |n| n == nif_obligado }
146
+ return if intrusos.empty?
147
+
148
+ raise ValidacionError,
149
+ "Todos los registros deben emitirlos el obligado de la cabecera " \
150
+ "(#{nif_obligado}). Ajenos: #{intrusos.join(', ')}"
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VerifactuRails
4
+ # Marcador común de todo error propio de la gema.
5
+ #
6
+ # Es un MÓDULO y no una clase base a propósito. `rescue` casa por `Module#===`,
7
+ # así que incluirlo basta para que funcione `rescue VerifactuRails::Error`, y
8
+ # a la vez deja libre la herencia para que cada error conserve la clase de Ruby
9
+ # que le corresponde semánticamente.
10
+ #
11
+ # En concreto, ValidacionError sigue siendo un ArgumentError, que es lo que
12
+ # cualquier rubyista espera al pasar un argumento inválido. Una jerarquía
13
+ # propia colgando de StandardError habría obligado a elegir entre una cosa y
14
+ # la otra.
15
+ #
16
+ # begin
17
+ # registro = VerifactuRails::RegistroAlta.new(...)
18
+ # rescue VerifactuRails::Error => e # cualquier cosa que venga de la gema
19
+ # ...
20
+ # end
21
+ #
22
+ # rescue ArgumentError # sigue atrapando las validaciones
23
+ module Error; end
24
+
25
+ # Datos que incumplen el diseño de registro o las validaciones de la AEAT.
26
+ # Se levanta al construir, nunca al serializar: si un objeto existe, su
27
+ # contenido es emitible.
28
+ class ValidacionError < ArgumentError
29
+ include Error
30
+ end
31
+ end