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