Un cliente nos escribió ayer con una pregunta que paraliza a cualquier dueño de gym: "¿Por qué un pago de un alumno mío aparece en la auditoría como si hubiera ocurrido en otro gimnasio?". La pregunta se respondía sola: si la auditoría dice que el pago fue en otro gym, o el sistema está roto o algo grave está pasando con la base de datos. Cualquiera de los dos es razón suficiente para no dormir tranquilo.

Investigamos. La conclusión es interesante porque destapa una decisión de diseño que parece sensata y resulta ser la causa exacta del problema: cuando un dato falta, "inferir uno razonable" puede ser peor que dejarlo vacío. Y en auditoría, peor todavía: puede atribuir una acción a un gimnasio ajeno por pura coincidencia de números.

Lo que el cliente vio

La auditoría mostraba esta secuencia, todas líneas con el mismo IP, el mismo alumno, el mismo minuto:

La última línea, la del pago, aparecía atribuida a un gimnasio distinto al que él opera. "¿Por qué Jeremías está pagando en Urban si es alumno de Apple? ¿Tiene cuenta allá también? ¿Le cobré yo o le cobró otro? ¿La plata cayó en mi cuenta de MercadoPago o en la de ellos?". Preguntas legítimas, escalando hacia el peor escenario en cada una.

El pago real estaba bien — el log no

Verificamos los registros financieros directamente:

Es decir: todos los datos financieros estaban correctos. Solo el log de auditoría mentía. Y mentía con una atribución específica, escogiendo el gimnasio "Urban" en particular en lugar de cualquier otro.

Por qué eligió ese gym y no otro

El bug es un caso de manual de "fallback razonable que terminó siendo desastroso". La función que escribe entradas en la auditoría tenía esta lógica para resolver el campo gym_id del registro:

  1. Si hay sesión de usuario → usá el gym_id del usuario.
  2. Si no hay sesión (webhook, acción anónima del miembro) → usá el id de la entidad sobre la que se registra la acción.
  3. Si tampoco hay → usá 0.

El paso 2 es donde estaba el veneno. La acción "inicio de pago de membresía" se registra como una operación sobre la entidad miembro, con el id de ese miembro. Jeremías es el miembro #38 en su gimnasio. Cuando el sistema cayó en el paso 2 del fallback, escribió gym_id = 38 en la entrada de auditoría. Y casualmente, existe un gimnasio con id = 38 en nuestra base — Urban Gym.

El log no estaba apuntando "a propósito" al gimnasio equivocado. Estaba escribiendo un número derivado de manera incorrecta, y la pantalla de auditoría hacía su trabajo: mostrar el nombre del gym cuyo ID matchee con ese número. La inferencia era técnicamente coherente y semánticamente disparatada.

Por qué un log que miente es peor que no tener log

Acá hay una idea que vale la pena destacar: el costo de información incorrecta es más alto que el costo de información ausente.

Si una entrada de auditoría hubiera dicho "gimnasio: desconocido", el dueño de Apple Gym habría visto algo raro y nos habría escrito igual. Hubiéramos investigado y encontrado el bug. Tiempo perdido: una hora.

Pero la entrada decía "gimnasio: Urban Gym". Eso le hizo pensar dos cosas terribles antes de escribirnos:

  1. "¿La plata cayó en otra cuenta de MercadoPago?" (no, pero la duda es razonable).
  2. "¿El sistema mezcla datos entre gimnasios?" (peor todavía como hipótesis — implicaría una falla de aislamiento multi-tenant).

Esa segunda hipótesis es la que rompe la confianza en cualquier software multi-tenant. Si el dueño de un gym empieza a sospechar que sus datos están "viajando" hacia otros gimnasios del sistema, ya no importa cuántas veces le expliques que no es así. La duda quedó plantada. Y la duda nació de un fallback "razonable".

Un campo vacío te hace preguntar. Un campo lleno con un dato falso te hace concluir.

El fix: cortar el fallback de raíz

La corrección fue chica en líneas de código y grande en filosofía. Cambiamos la función para que ya no infiera el gym_id del id de la entidad. Ahora:

  1. Si hay sesión → usá el gym_id del usuario.
  2. Si no hay sesión, el caller debe pasar el gym_id explícito como parámetro.
  3. Si nadie pasa nada → grabar gym_id = 0 y que la pantalla muestre "(sin gym)".

Después fuimos por todos los lugares del código donde se escribía auditoría sin sesión (webhooks de MercadoPago, eventos de AFIP, auto-checkout del miembro desde la app, intentos de registro bloqueados) y agregamos el gym_id explícito en cada uno. Doce sitios en total. Cada uno conoce su gym_id en el contexto donde corre — no hace falta inferirlo.

El nuevo principio:

Si el dato no está, que falte. Nunca lo derives "razonablemente" de otro campo. Una auditoría que muestra "—" en una columna es honesta. Una auditoría que muestra un valor inferido es deshonesta, aunque sea por casualidad.

Lo que esto significa para el dueño del gym

Si vos sos dueño de un gym y mirás la pantalla de auditoría de tu sistema, hay tres cosas que vale la pena exigir del software:

1. Que cada columna sea explícitamente seteada, nunca inferida.

Cuando una entrada dice "Maite registró un alta a las 10:24am desde la sede Centro", cada uno de esos cuatro datos (operador, acción, hora, sede) tendría que estar guardado como un valor específico al momento de escribir el log. Si alguno está derivado a posteriori de otro campo, hay riesgo de mentira.

2. Que los huecos sean visibles.

Si por alguna razón un dato no se pudo capturar (acción de un webhook que no conoce el operador, por ejemplo), la pantalla tiene que mostrar "—" o "sistema", no inventar uno "razonable". Eso te permite preguntar y descubrir bugs como el que arreglamos esta semana.

3. Que los datos críticos (sede, monto, miembro) tengan un único origen.

El registro de auditoría debería capturar esos campos al momento del evento, no recalcularlos al momento de mostrarlos. Si la sede que ves en el log fue derivada de "donde está hoy el cajero", podría ser distinta a "donde estaba el cajero cuando hizo la operación hace tres semanas". Auditar bien implica registrar el contexto del momento, no la deducción posterior.

Lo que sigue

Este fix abrió una pregunta más amplia que ya está en roadmap:

Ninguno de estos arregla el bug que tuvimos esta semana. Ese ya está cerrado. Pero todos apuntan al mismo principio: una auditoría que el dueño puede creer es la base sobre la cual se sostienen todas las demás decisiones de confianza con su software. Si hay alguna columna donde el sistema "se la juega" e infiere, esa columna es el eslabón débil de toda la cadena.

El reflejo más grande

Programadores con experiencia conocen este patrón. Los lenguajes y frameworks favorecen los "valores por defecto razonables" porque hacen que el código se vea limpio y los casos felices sean cortos. Pero "razonable" en código y "razonable" en producción no siempre coinciden — porque en producción los IDs colisionan, los webhooks llegan tarde, las entidades pueden compartir números entre tablas, y un valor por defecto que tiene sentido lógico puede no tener ningún sentido semántico.

La regla que aplicamos a partir de este bug, y que se va a propagar a otras partes del sistema: en datos de auditoría e integridad, preferimos campos vacíos a campos inferidos. Un hueco visible es una invitación a investigar; un valor mentiroso es una conclusión mal informada. La diferencia es enorme.

Auditoría que se puede leer en serio

Cada acción del staff y de los alumnos queda registrada con campos explícitos: usuario, acción, sede, IP, fecha, contexto. GymFlow, 30 días gratis sin tarjeta de crédito.

Probar gratis 30 días →

Artículos relacionados