La Unidad Fija (UF), el número con el que se calculan todas las multas de tránsito en la Provincia de Buenos Aires, pasó a $2.281 en septiembre de 2026. Venía de $2.271 en el bimestre julio-agosto: una suba del 0,44%, bastante menor que el 2,53% del ajuste anterior. En la Ciudad de Buenos Aires no se movió, sigue en $949,99, porque allá el ajuste es semestral y no bimestral.
Ese es el dato que la gente busca. Y es, exactamente, el dato que se nos quedaba viejo en medio sitio cada dos meses.
Este post cuenta cómo lo resolvimos, porque el problema es más general de lo que parece: un valor oficial, publicado por un tercero, que cambia en una fecha que no controlás y sin avisarte. Si alguna vez publicaste un precio de dólar, una tasa, un índice de inflación o un valor de referencia legal, es el mismo problema.
Por qué una multa no está en pesos
En Argentina una infracción de tránsito no se expresa en un monto fijo. Se expresa en Unidades Fijas. La norma dice que una infracción vale una cantidad de UF, y aparte, otro organismo fija cuánto vale una UF.
monto = cantidad_de_UF_de_la_infracción * valor_vigente_de_la_UF
En la Provincia de Buenos Aires una UF equivale al precio de un litro de nafta de mayor octanaje, y el Ministerio de Transporte la ajusta cada dos meses. En la Ciudad de Buenos Aires equivale a medio litro, la fija el Instituto de Estadística de la Ciudad y el ajuste es semestral. Dos fuentes, dos ritmos, cero webhooks.
La consecuencia de producto es fuerte: el monto de una deuda no se puede guardar. Hay que recalcularlo cada vez, con el valor vigente. Una multa del año pasado vale más hoy sin que nadie haya hecho nada.
Y la consecuencia técnica es este post.
El estado inicial, que es el estado de casi todos
El número arrancó donde arrancan todos los números: escrito en el lugar donde hacía falta. Un artículo decía "la UF vale $2.271". Otro también. La calculadora tenía su constante. La página de valores tenía la suya.
Trece lugares. Ninguno sabía de los otros.
Mientras el valor no cambia, esto no duele. El problema es que cambia cada dos meses, y actualizar trece archivos a mano tiene una tasa de éxito que no es 100%. Nunca es 100%. Lo que pasa en la práctica es que actualizás los tres que te acordás, los que salen primero en la búsqueda, y los otros diez siguen mostrando el valor del bimestre pasado.
Ahí el costo deja de ser técnico. Un sitio que publica cifras oficiales y muestra dos cifras distintas para la misma cosa pierde exactamente lo único que vende, que es que el número sea confiable. Peor todavía cuando buena parte del tráfico viene de gente buscando "cuánto vale la UF", y hoy también de modelos de lenguaje que leen la página y la citan. Un LLM que encuentra dos valores contradictorios en el mismo dominio no elige el correcto: te descarta como fuente.
Capa 1: una sola fuente, aburrida a propósito
Lo primero fue lo obvio. Un módulo, sin dependencias, que exporta los valores y nada más.
// uf.values.mjs
export const UF_PBA = 2281;
export const UF_CABA = 949.99;
export const UF_VIGENTE = 'septiembre 2026';
Dos decisiones chicas que después valieron mucho:
Es .mjs, no .ts. Porque lo tiene que importar el sitio, pero también el archivo de configuración del build, que corre en Node antes de que exista cualquier transpilación. Un .ts ahí obliga a un paso extra o a duplicar el valor, que es justo lo que estábamos sacando.
No tiene lógica de negocio. Es un archivo que se puede abrir, leer en tres segundos y editar sin entender el resto del sistema. Cuando el Estado publique el próximo ajuste, la tarea es cambiar dos números. Si para actualizar la UF hay que entender la arquitectura, alguien la va a actualizar mal.
Capa 2: tokens que se resuelven en el build
La parte difícil no era el código, era el contenido. Los artículos son Markdown. No pueden importar un módulo.
La solución fue meter tokens en el texto y resolverlos como paso del build:
A {{uf:vigente}} la UF de Provincia quedó en **{{uf:pba}}**.
La multa más cara, de 1.000 UF, llega a {{uf:pba*1000}}.
El resolver es un par de líneas, y la única gracia es que soporta multiplicación, porque la mitad de las menciones en los textos no son "el valor de la UF" sino "cuánto sale una infracción de 300 UF":
export function resolveUfToken(expr) {
const e = String(expr || '').trim();
if (e === 'vigente') return UF_VIGENTE;
const m = /^(pba|caba)(?:\s*\*\s*(\d+(?:[.,]\d+)?))?$/.exec(e);
if (!m) return null; // null = token inválido, ver capa 3
const base = m[1] === 'pba' ? UF_PBA : UF_CABA;
const mult = m[2] ? Number(m[2].replace(',', '.')) : 1;
if (!Number.isFinite(mult) || mult <= 0) return null;
return formatearPesos(base * mult);
}
Se engancha como integración del build, que en Astro es un hook, pero el patrón es el mismo en cualquier generador estático: interceptar el contenido antes de que se escriba el HTML.
Ahora actualizar la UF es editar dos números. Los 151 tokens repartidos en 16 archivos se resuelven solos.
Capa 3: la que importa de verdad
Acá está la parte que quiero dejar escrita, porque es la que se suele omitir.
Cuando armás un sistema de sustitución, el modo de falla no es que el número salga mal. El modo de falla es que salga {{uf:pba}}. Un token crudo, publicado, en la página que la gente lee y que los buscadores indexan. Es peor que el valor viejo: el valor viejo se ve como un error de actualización, el token crudo se ve como un sitio roto.
Un typo alcanza. {{uf:pva}} en lugar de {{uf:pba}}. El regex no matchea, el reemplazo no ocurre, y si tu resolver es tolerante devolvés el string original y lo publicás.
Por eso el resolver devuelve null en vez de devolver el token, y por eso el build falla:
const malos = [];
// ...por cada archivo, por cada token:
const resuelto = resolveUfToken(expr);
if (resuelto === null) malos.push(`${archivo}: token inválido ${match}`);
if (malos.length) {
throw new Error(`Tokens inválidos: ${malos.length}.`);
}
Y después, una segunda pasada sobre el HTML ya escrito, que es cinturón y tiradores a propósito:
// GUARDIA: que no quede NINGÚN token sin resolver en lo que se sirve
const quedaron = archivosDeSalida.filter((f) => leer(f).includes('{{uf:'));
if (quedaron.length) {
throw new Error(`Quedaron tokens sin resolver en: ${quedaron.join(', ')}`);
}
Esa segunda pasada parece redundante y no lo es. La primera valida los tokens que encontró. La segunda valida el artefacto que se va a servir, que es lo único que le llega al usuario. Si mañana alguien agrega otra fuente de contenido que no pasa por el mismo pipeline, la primera no la ve y la segunda sí.
La regla, dicha corta: si el dato es público y verificable, un deploy con el dato roto es peor que un deploy que no sale. Que se caiga el build.
Capa 4: que no dependa del deploy
Con todo lo anterior, actualizar la UF todavía requiere que alguien edite el archivo y haga push. Es rápido, pero es una persona en el camino crítico de un dato que cambia solo.
Así que arriba de eso hay una capa viva. Un scraper propio lee los valores oficiales y los deja disponibles en una API interna. El sitio la consulta y sincroniza el valor en el cliente:
- Los valores del archivo son el piso: lo que ve el crawler, lo que se pinta en el primer render, lo que queda si todo lo demás falla.
- La API es el techo: si responde, el visitante ve el valor de hoy sin esperar un deploy.
La respuesta se cachea, con TTL, en el borde. Y la degradación es lo único que hay que pensar bien:
const pba = Number(data?.pba);
const caba = Number(data?.caba);
if (!(pba > 0) || !(caba > 0)) return error(502); // dato raro: no lo propagues
Ese chequeo es más importante de lo que parece. Un 0, un null o un string que se cuela desde arriba no rompe nada visiblemente: publica un monto equivocado, que es el peor resultado posible. Es más seguro no actualizar que actualizar con basura. Si la validación no pasa, el sitio se queda con el valor del archivo y nadie se entera.
Sumado, el orden de confianza queda: valor vivo validado, después valor del archivo, después nada. Nunca se llega a "nada".
Lo que me llevo
- Un dato que cambia solo no puede vivir en el lugar donde se muestra. No es una cuestión de prolijidad: la actualización manual falla parcialmente, y fallar parcialmente es peor que fallar entero, porque te deja el sitio contradiciéndose consigo mismo.
- El sistema de reemplazo necesita un guard que rompa el build. Sin eso cambiaste "dato viejo" por "token crudo".
- Validá el dato vivo antes de mostrarlo. Un valor inválido que se propaga es peor que una actualización que no ocurre.
- El fallback tiene que ser un valor real, no un vacío. Si la API se cae, la página sigue diciendo un número correcto de hace un bimestre en vez de un guion.
Nada de esto es sofisticado. Es un archivo, un reemplazo en build y dos excepciones. Pero convirtió "actualizar la UF" de una tarea de trece pasos que salía mal en una de dos números que no puede salir mal en silencio.
Los valores, por si llegaste buscando el dato
| Bimestre | UF Provincia de Buenos Aires |
|---|---|
| Mayo-junio 2026 | $2.215 |
| Julio-agosto 2026 | $2.271 |
| Septiembre-octubre 2026 | $2.281 |
En CABA la UF vale $949,99 y se ajusta por semestre, así que no acompaña estos movimientos.
Con la UF a $2.281, una infracción de 300 UF (cruzar el semáforo en rojo, VTV vencida) sale $684.300, y el tope de 1.000 UF llega a $2.281.000.
- El desglose completo, con los montos por tipo de infracción, está en la nota del aumento de septiembre.
- Si querés entender la Unidad Fija desde cero, qué es la UF y cómo se calcula lo explica con el histórico, y hay una calculadora de UF a pesos.
- Todo esto sale de Multita, que consulta infracciones en 32 jurisdicciones argentinas por patente, DNI o CUIT. Si lo tuyo es integrarlo, la API devuelve las actas en JSON con el monto ya convertido a pesos al valor vigente, que es precisamente el problema que este post describe, resuelto del lado del servidor.
Si tenés un dato oficial de esos que cambian cada tanto y hoy vive en varios lugares de tu repo, el patrón es portable y se implementa en una tarde. La parte que no hay que saltearse es la tercera.
Top comments (0)