El costo oculto de los errores de mod-11 en tu onboarding LATAM
Módulo 11 es el algoritmo de dígito verificador más usado en identificadores tributarios de LATAM. Lo usan CUIT/CUIL (Argentina), CPF y CNPJ (Brasil), RUT (Chile), NIT (Colombia), RUC (Perú) y RIF (Venezuela). Pero esa frase es engañosa, porque "mod-11" no describe un algoritmo único — describe una familia de algoritmos. Cada país tiene su propia variante con su propia matriz de pesos, su propia regla para el caso resto=10, su propia regla para el caso de patrones uniformes, y su propia convención de capitalización. Las implementaciones caseras que tratan "mod-11" como un solo cálculo y solo cambian la longitud son las que generan onboardings rotos, falsos negativos en flujos B2B y batch jobs que rechazan empresas perfectamente válidas. Este post recorre las variantes país por país, los errores más frecuentes que vemos y una implementación de referencia open de cómo manejamos CUIT en Normadata.
Mod-11 no es uno — es una familia
El esqueleto común del módulo 11: multiplicás cada dígito del cuerpo por un peso de una matriz, sumás los productos, calculás suma mod 11, y aplicás una regla para convertir el resto en el dígito verificador. Lo que cambia entre países es: (a) qué dígitos forman el cuerpo, (b) qué matriz de pesos se usa, (c) qué hacer cuando el resto es 10 (algunos países lo convierten en K, otros en 0, otros declaran el ID inválido), (d) si el algoritmo se aplica una sola vez o dos veces (CPF y CNPJ usan dos pasos consecutivos), (e) si existen patrones uniformes que pasan el algoritmo pero son inválidos por regla del registro. Tratar todos estos como variantes triviales del mismo cálculo es la fuente de la mayoría de los bugs que vemos en código de producción.
Variantes principales país por país
Acá va la matriz concreta de las seis variantes mod-11 más comunes en LATAM, escritas como las usaríamos en una implementación:
- CUIT (AR) — Cuerpo: 10 primeros dígitos (prefijo de 2 + número de 8). Pesos: [5,4,3,2,7,6,5,4,3,2]. Regla: check = 11 - (suma mod 11). Si check = 11 → DV = 0. Si check = 10 → CUIT inválido con ese prefijo (AFIP re-emite con prefijo alternativo 23/24/33/34).
- CPF (BR) — Dos DVs. Primer DV: cuerpo = 9 dígitos base, pesos = [10,9,8,7,6,5,4,3,2]. suma mod 11; si resto < 2 → DV = 0, si no → DV = 11 - resto. Segundo DV: cuerpo = 10 dígitos (base + primer DV), pesos = [11,10,9,8,7,6,5,4,3,2]. Misma regla. Además: los 10 patrones de todos los dígitos iguales (000.000.000-00 a 999.999.999-99) son inválidos por regla de la Receita aunque pasen el algoritmo.
- CNPJ (BR) — Dos DVs. Primer DV: cuerpo = 12 dígitos base, pesos = [5,4,3,2,9,8,7,6,5,4,3,2]. Notar que los pesos no son monotónicamente descendentes — bajan a 2 y luego saltan a 9. Segundo DV: cuerpo = 13 dígitos (base + primer DV), pesos = [6,5,4,3,2,9,8,7,6,5,4,3,2]. Misma regla de all-equal que CPF.
- RUT (CL) — Cuerpo: dígitos del RUT excluyendo el DV. Pesos cíclicos [2,3,4,5,6,7] aplicados desde la derecha. suma mod 11. Si resto = 11 → DV = 0. Si resto = 10 → DV = 'K' (mayúscula). Otro resto → DV = 11 - resto.
- NIT (CO) — Cuerpo: hasta 15 dígitos base (típicamente 9). Pesos primos [3,7,13,17,19,23,29,37,41,43,47,53,59,67,71] aplicados desde la derecha. Regla: si resto < 2 → DV = resto, si no → DV = 11 - resto.
- RUC (PE) — Cuerpo: 10 primeros dígitos. Pesos: [5,4,3,2,7,6,5,4,3,2]. Mismos pesos que CUIT pero distinta semántica (RUC empieza con 10 para naturales o 20 para jurídicas). suma mod 11; resto en {0,1} → DV = 0/1 según convención SUNAT, otros → DV = 11 - resto.
Errores comunes en implementaciones caseras
Estos son los bugs que más vemos en code review de implementaciones internas de validación:
- Olvidar que CPF y CNPJ rechazan all-equal patterns — Un CPF como 111.111.111-11 pasa el algoritmo mod-11 matemáticamente, pero la Receita Federal nunca lo emitió y todo validador correcto lo debe rechazar. Implementaciones caseras frecuentemente lo dejan pasar porque solo testean el algoritmo en abstracto.
- Confundir K mayúscula vs minúscula en RUT — La convención SII es K mayúscula. Algunos sistemas almacenan k minúscula, otros la convierten a 10. Si comparás strings directos, 11.222.333-K vs 11.222.333-k vs 11.222.333-10 dan resultados distintos. Siempre normalizá a mayúscula antes de comparar.
- Aplicar el algoritmo de CUIT al CUIL (o viceversa) — Mismo algoritmo, distinta semántica. Un código que rechaza un CUIL por "no es CUIT" o que valida cualquier CUIT como CUIL sin contexto está mal en el momento equivocado. Tener un campo "tipo" separado del valor.
- Calcular DV de CNPJ con pesos de CPF — Los pesos del primer DV de CNPJ son [5,4,3,2,9,8,7,6,5,4,3,2], no una extensión monotónica de los pesos de CPF. Esta confusión es sorprendentemente frecuente en code copiado de StackOverflow.
- No manejar el caso resto = 10 correctamente — Para CUIT, resto = 10 significa CUIT inválido con ese prefijo (no DV = 10). Para RUT, significa DV = 'K'. Tratar ambos casos como DV = 10 numérico produce IDs imposibles.
- Validar con regex de longitud sin chequear DV — Una regex que solo verifica `^\d{11}$` para CPF rechaza CPFs con puntos válidos y acepta strings como 12345678901 que matemáticamente no son válidos. La regex no reemplaza el cálculo.
- Usar integers para el valor — JavaScript / Python tratan ceros a la izquierda como significativos solo en strings. Convertir el ID a número pierde ceros iniciales y rompe RUTs de 7 dígitos o RUCs que empiezan con cero (raro pero existe en algunos países).
- No normalizar separadores antes de validar — Un CUIT puede llegar como 30-50001091-2, 30.50001091.2, 30 50001091 2, o 30500010912. Todos son el mismo identificador. Un strip de no-dígitos (excepto K para RUT) antes de calcular es obligatorio.
- Ignorar el orden de iteración de los pesos — Algunos algoritmos aplican los pesos de izquierda a derecha (CUIT), otros de derecha a izquierda (NIT, RUT). Reverse del array vs iteración inversa son cosas distintas y producen DVs distintos.
Cómo Normadata maneja todas las variantes
En el catálogo interno (lib/catalog/validator-data.ts) cada país × identifier mapea a un descriptor con su algoritmo específico, su matriz de pesos, sus reglas de borde y sus patrones inválidos por regla del registro. El validador client-side (los tools en /tools) mirrorea exactamente la lógica del backend — no es una aproximación, es la misma función ejecutada en JavaScript en el browser. Eso significa que el mismo input que el backend valida como inválido, el frontend lo marca como inválido instantáneamente, sin round-trip a la API. La fuente de verdad es una sola.
Caveat estructural: el DV no confirma registro
Un punto que vale repetir: validar matemáticamente un dígito verificador es validar estructura. No es validar que el identificador exista en AFIP, Receita Federal, SAT, SII, DIAN, SUNAT o SENIAT. Un CPF estructuralmente válido puede ser de una persona que no existe, de una persona que ya falleció, o de una persona viva pero cuyo CPF fue cancelado. Para confirmar registro o estado activo, necesitás un servicio que efectivamente consulte el registro oficial — y ese servicio es independiente de Normadata. Lo que sí podés afirmar después de la validación de Normadata es que el identificador podría existir: cumple toda la estructura matemática y semántica que un identificador real cumple. Esa es la garantía explícita; la inversa (que existe) no la podemos hacer sin tocar el registro.
Implementación de referencia (TypeScript)
Acá va cómo se ve la implementación de CUIT y CPF en TypeScript, escrita con claridad sobre concisión para que sirva como referencia. La idea es que si vas a implementar mod-11 caseramente, al menos uses estas como punto de partida y no algo bajado de StackOverflow sin entender los edge cases.
const CUIT_WEIGHTS = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2] as const;
const VALID_PREFIXES = new Set(["20", "23", "24", "27", "30", "33", "34"]);
export function isValidCuit(input: string): boolean {
const digits = input.replace(/\D/g, "");
if (digits.length !== 11) return false;
const prefix = digits.slice(0, 2);
if (!VALID_PREFIXES.has(prefix)) return false;
const body = digits.slice(0, 10).split("").map(Number);
const dv = Number(digits[10]);
let sum = 0;
for (let i = 0; i < 10; i++) {
sum += body[i] * CUIT_WEIGHTS[i];
}
const remainder = sum % 11;
const check = 11 - remainder;
if (check === 11) return dv === 0;
if (check === 10) return false; // CUIT invalido con ese prefijo
return dv === check;
}function calcCpfCheckDigit(digits: number[], weights: number[]): number {
let sum = 0;
for (let i = 0; i < digits.length; i++) {
sum += digits[i] * weights[i];
}
const remainder = sum % 11;
return remainder < 2 ? 0 : 11 - remainder;
}
export function isValidCpf(input: string): boolean {
const digits = input.replace(/\D/g, "");
if (digits.length !== 11) return false;
// Regla Receita: rechazar patrones con los 11 digitos iguales.
if (/^(\d)\1{10}$/.test(digits)) return false;
const nums = digits.split("").map(Number);
const base = nums.slice(0, 9);
const dv1 = calcCpfCheckDigit(base, [10, 9, 8, 7, 6, 5, 4, 3, 2]);
if (dv1 !== nums[9]) return false;
const base2 = nums.slice(0, 10);
const dv2 = calcCpfCheckDigit(base2, [11, 10, 9, 8, 7, 6, 5, 4, 3, 2]);
return dv2 === nums[10];
}curl -X POST https://api.normadata.io/v1/verify/tax-id \
-H "X-API-Key: nd_your_key_here" \
-H "Content-Type: application/json" \
-d {
"value": "111.111.111-11",
"country": "BR",
"type": "cpf"
}curl -X POST https://api.normadata.io/v1/verify/tax-id \
-H "X-API-Key: nd_your_key_here" \
-H "Content-Type: application/json" \
-d {
"value": "23-25861053-4",
"country": "AR",
"type": "cuit"
}Conclusión
Si tu producto opera en un solo país de LATAM, escribir y mantener una implementación correcta de mod-11 para ese país es totalmente factible — usá las referencias de arriba como punto de partida y testealos contra casos conocidos en producción. Si operás en dos o más países, la pregunta no es si vale la pena escribir N implementaciones caseras; es si vale la pena mantenerlas todas en sincronía cuando cada país publica edge cases nuevos. El stack típico — N variantes × M lenguajes × bugs de borde — escala mal. La alternativa es delegar el algoritmo a Normadata via /v1/verify/tax-id (mismo endpoint, todos los países) o, si preferís quedarte con OSS, basarse en las librerías canónicas mantenidas (como python-stdnum para Python). Para la referencia completa del endpoint, ver /docs/api/verify-tax-id. Para la cobertura por país e identificador, /coverage. Para la guía completa de tax IDs LATAM, /blog/latam-tax-ids-developer-guide.