Module: VerifactuRails::Formato
- Defined in:
- lib/verifactu_rails/formato.rb
Overview
Normalización canónica de los valores que aparecen a la vez en la cadena de la huella y en el XML.
REGLA DE ORO, la misma que rige Importe: la AEAT recalcula la huella sobre lo que recibe en el XML. Si un valor se normaliza distinto en cada sitio, rechazo. Por eso fechas y marcas temporales viven aquí y no duplicadas en cada módulo.
(El escapado XML es harina de otro costal y no rompe esta regla: la huella se calcula sobre el valor crudo y el XML lo escapa, pero la AEAT desescapa antes de recalcular. Una serie "A&B" va cruda a la huella y como "A&B" al XML.)
Constant Summary collapse
- PROHIBIDOS_SERIE =
NumSerieFactura solo admite ASCII imprimible (32-126) y prohíbe además cinco caracteres concretos (Validaciones v1.2.2, ap. 3.1.3.1).
Ojo a la asimetría: "&" SÍ está permitido, y es justo el que obliga a escapar en el XML mientras la huella lo usa crudo. Los que romperían el XML de verdad (< > ") están prohibidos de entrada por la AEAT.
['"', "'", '<', '>', '='].freeze
Class Method Summary collapse
-
.coleccion(valor, campo, clase, maximo: nil) ⇒ Object
Colección homogénea de objetos de valor.
- .enumerado(valor, campo, admitidos) ⇒ Object
-
.fecha(valor) ⇒ Object
Tipo sf:fecha del XSD: exactamente dd-mm-yyyy.
- .limitar(valor, campo, maximo) ⇒ Object
-
.marca_temporal(valor) ⇒ Object
ISO 8601 con offset explícito.
-
.nif(valor, campo = 'NIF') ⇒ Object
Longitud fija de 9 según sf:NIFType.
- .num_serie(valor, campo = 'NumSerieFactura') ⇒ Object
-
.objeto(valor, campo, clase) ⇒ Object
Colaborador obligatorio de una clase concreta.
-
.si_no(valor, campo) ⇒ Object
Campos sf:SiNoType.
-
.texto(valor, campo) ⇒ Object
Espacios al inicio y al final: la spec NO es ambigua, dice recortarlos.
Class Method Details
.coleccion(valor, campo, clase, maximo: nil) ⇒ Object
Colección homogénea de objetos de valor.
No basta con Array(): Array(hash) devuelve los PARES del hash, así que un
destinatarios: {nombre_razon: 'X', nif: '...'} se convertía en dos
"destinatarios" que pasaban las validaciones de conteo y solo reventaban
al serializar, con un NoMethodError sobre un Array. Aquí el criterio es el
mismo que en el resto de la gema: si el objeto se construye, es emitible.
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 |
# File 'lib/verifactu_rails/formato.rb', line 159 def coleccion(valor, campo, clase, maximo: nil) lista = valor.nil? ? [] : Array(valor) if valor.is_a?(Hash) || (valor && !valor.is_a?(Array) && !valor.is_a?(clase)) raise ValidacionError, "#{campo} debe ser un #{clase} o un array de #{clase} " \ "(recibido: #{valor.class})" end lista = [valor] if valor.is_a?(clase) intrusos = lista.reject { |e| e.is_a?(clase) }.map(&:class).uniq unless intrusos.empty? raise ValidacionError, "#{campo} solo admite #{clase} (encontrados: #{intrusos.join(', ')})" end if maximo && lista.size > maximo raise ValidacionError, "#{campo} admite como mucho #{maximo} elementos (recibidos #{lista.size})" end lista end |
.enumerado(valor, campo, admitidos) ⇒ Object
189 190 191 192 193 194 195 196 197 |
# File 'lib/verifactu_rails/formato.rb', line 189 def enumerado(valor, campo, admitidos) cadena = texto(valor, campo) unless admitidos.include?(cadena) raise ValidacionError, "#{campo} inválido: #{cadena.inspect}. Admitidos: #{admitidos.join(', ')}" end cadena end |
.fecha(valor) ⇒ Object
Tipo sf:fecha del XSD: exactamente dd-mm-yyyy.
46 47 48 49 50 51 52 53 54 55 56 57 58 |
# File 'lib/verifactu_rails/formato.rb', line 46 def fecha(valor) objeto = valor.is_a?(String) ? Date.strptime(valor, '%d-%m-%Y') : valor # Guarda explícita, como en marca_temporal: sin ella, un nil se escapaba # del contrato de errores del módulo con un NoMethodError en vez de un # ValidacionError, porque el rescue de abajo no captura NoMethodError. unless objeto.respond_to?(:strftime) raise ValidacionError, "Fecha inválida (se espera Date o 'dd-mm-yyyy'): #{valor.inspect}" end objeto.strftime('%d-%m-%Y') rescue ArgumentError, TypeError raise ValidacionError, "Fecha inválida (se espera Date o 'dd-mm-yyyy'): #{valor.inspect}" end |
.limitar(valor, campo, maximo) ⇒ Object
125 126 127 128 129 130 131 132 133 |
# File 'lib/verifactu_rails/formato.rb', line 125 def limitar(valor, campo, maximo) cadena = texto(valor, campo) if cadena.length > maximo raise ValidacionError, "#{campo} excede #{maximo} caracteres (#{cadena.length}): #{cadena.inspect}" end cadena end |
.marca_temporal(valor) ⇒ Object
ISO 8601 con offset explícito. OJO: Ruby serializa UTC como "Z" mientras que la referencia usa "+00:00"; forzamos siempre ±HH:MM para que la huella coincida con la de otras implementaciones y con el XML.
63 64 65 66 67 68 69 70 |
# File 'lib/verifactu_rails/formato.rb', line 63 def marca_temporal(valor) tiempo = valor.is_a?(String) ? Time.iso8601(valor) : valor raise ValidacionError, 'fecha_hora_gen debe ser Time o String ISO 8601' unless tiempo.respond_to?(:strftime) tiempo.strftime('%Y-%m-%dT%H:%M:%S%:z') rescue ArgumentError => e raise ValidacionError, "fecha_hora_gen inválida: #{valor.inspect} (#{e.})" end |
.nif(valor, campo = 'NIF') ⇒ Object
Longitud fija de 9 según sf:NIFType. El XSD no valida el dígito de control,
y nosotros tampoco: rechazar un NIF válido por una tabla desactualizada
sería peor que dejar que la AEAT lo rechace.
Se normaliza a MAYÚSCULAS, y esto sí es una excepción deliberada a la regla
de "rechazar en vez de arreglar" que rige texto.
Motivo, comprobado contra preproducción: un NIF con la letra en minúscula ("89890001k") supera la validación de FORMATO de la AEAT (no da 4116) pero falla la búsqueda en el censo, que sí distingue mayúsculas, y devuelve un 4104 "no está identificado" que apunta al sitio equivocado: parece que el NIF no existe cuando lo único que pasa es que va en minúscula.
La diferencia con los espacios al borde es que ahí el valor original podía significar algo (un campo de ancho fijo mal recortado, por ejemplo) y recortar lo taparía. Aquí no: la forma canónica de un NIF es en mayúsculas y "c" y "C" denotan la misma letra de control. No se pierde información.
88 89 90 91 92 93 94 95 |
# File 'lib/verifactu_rails/formato.rb', line 88 def nif(valor, campo = 'NIF') cadena = texto(valor, campo).upcase unless cadena.length == 9 raise ValidacionError, "#{campo} debe tener 9 caracteres: #{cadena.inspect}" end cadena end |
.num_serie(valor, campo = 'NumSerieFactura') ⇒ Object
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
# File 'lib/verifactu_rails/formato.rb', line 105 def num_serie(valor, campo = 'NumSerieFactura') cadena = limitar(valor, campo, 60) malos = cadena.chars.reject { |c| c.ord.between?(32, 126) }.uniq unless malos.empty? raise ValidacionError, "#{campo} solo admite ASCII imprimible (32-126); sobran: " \ "#{malos.map(&:inspect).join(', ')}" end encontrados = PROHIBIDOS_SERIE.select { |c| cadena.include?(c) } unless encontrados.empty? raise ValidacionError, "#{campo} no admite los caracteres #{encontrados.map(&:inspect).join(', ')}: " \ "#{cadena.inspect}" end cadena end |
.objeto(valor, campo, clase) ⇒ Object
Colaborador obligatorio de una clase concreta.
183 184 185 186 187 |
# File 'lib/verifactu_rails/formato.rb', line 183 def objeto(valor, campo, clase) return valor if valor.is_a?(clase) raise ValidacionError, "#{campo} debe ser un #{clase} (recibido: #{valor.class})" end |
.si_no(valor, campo) ⇒ Object
Campos sf:SiNoType. Acepta el booleano de Ruby y también la letra de la AEAT, porque quien lee la documentación escribe 'S'/'N' de forma natural.
Lo que NO puede pasar es tratarlo como valor de verdad a secas: 'N' es
truthy en Ruby, así que multi_ot ? 'S' : 'N' emitía 'S' cuando el usuario
había pedido 'N', invirtiendo la declaración en silencio.
141 142 143 144 145 146 147 148 149 150 |
# File 'lib/verifactu_rails/formato.rb', line 141 def si_no(valor, campo) case valor when nil then nil when true, 'S' then 'S' when false, 'N' then 'N' else raise ValidacionError, "#{campo} debe ser true/false o 'S'/'N' (recibido: #{valor.inspect})" end end |
.texto(valor, campo) ⇒ Object
Espacios al inicio y al final: la spec NO es ambigua, dice recortarlos.
"Los valores de los campos deberán tener la misma información contenida
en el campo correspondiente del fichero XML, pero eliminando los
espacios al inicio y al final de cada valor"
(Especificaciones huella v0.1.2, ap. 3)
Aquí rechazamos en vez de recortar, que es MÁS ESTRICTO que la norma: un valor con espacios al borde casi siempre es un defecto de los datos de origen, y recortar en silencio lo taparía. Los espacios interiores sí se respetan ("12345678 / G33" es un NumSerieFactura válido y así lo ejemplifica la propia AEAT).
33 34 35 36 37 38 39 40 41 42 43 |
# File 'lib/verifactu_rails/formato.rb', line 33 def texto(valor, campo) cadena = valor.to_s raise ValidacionError, "#{campo} no puede estar vacío" if cadena.empty? if cadena != cadena.strip raise ValidacionError, "#{campo} contiene espacios al inicio o final: #{cadena.inspect}. " \ 'Normalízalo antes de generar el registro.' end cadena end |