Para quien quiera saber qué hay adentro

Cómo armamos Contámelo

Las herramientas que elegimos, los desafíos que enfrentamos, y las decisiones técnicas que hicimos para que todo funcione sin dramas.

Quieres saber cómo nació todo esto? Lee la historia detrás de Contámelo

¿Por qué estos trade-offs?

Contámelo nace de un proyecto personal donde la velocidad y la claridad eran lo primero. Elegimos herramientas que permitieran crear rápido sin sacrificar la confianza.

Cada elección que ves abajo responde a una pregunta: ¿qué nos permite iterar rápido? ¿Dónde no podemos fallar? ¿Cómo protegemos los datos de nuestros usuarios?

check_circle

Velocidad de desarrollo

Vite, React, FastAPI — todo pensado para iterar sin fricciones.

check_circle

Confianza

El código es testeable, la arquitectura es clara, los datos están protegidos.

check_circle

Sin sorpresas

Tokens de corta vida, cookies seguras, validación en cada capa.

La caja de herramientas

Cada cosa se eligió por una razón. No por moda.

palette

Frontend

React 19

La UI reacciona a los cambios sin lag. Renders optimizados, hooks modernos.

Fast + confiable

Vite + Tailwind

Compilación instantánea en desarrollo. Estilos con tokens, sin sorpresas de CSS.

Desarrollo fluido

shadcn/ui

Componentes accesibles, listos para estilizar bajo nuestro design system.

Accesible

openapi-typescript

Los tipos del frontend se generan automáticamente del backend. Si algo cambia allá, el frontend lo sabe.

Zero inconsistencias

settings

Backend

FastAPI

Python moderno, async nativo. La documentación de la API se genera sola. Rápido de verdad.

Python 3.12+

SQLModel

Un modelo = tabla en BD + esquema de validación. Sin duplicación, sin confusión.

Clean + DRY

Clean Architecture

Domain → Application → Infrastructure. El código es testeable, mantenible, y las reglas de negocio viven en el corazón, no en la BD.

Escalable

uv (Astral)

Gestor de paquetes Python moderno. Las dependencias se resuelven en milisegundos, no minutos.

Rápido

cloud_sync

Cloud & Inteligencia

PostgreSQL + Neon

BD relacional serverless. Escala a cero cuando nadie la usa, pero está lista al instante.

Sin sorpresas de costos

Cloudflare R2

Almacenamiento S3-compatible. Pero acá el egress es gratis. Sin miedo a factura sorpresa por bajar avatares.

Predecible

OpenAI + Whisper

IA que entiende voz, texto, fotos. Para que anotar una deuda tome segundos, no minutos.

UX mágica

Resend

Correos transaccionales que llegan. Verificación, recuperación de contraseña, confiable.

Confiable

Desafíos que enfrentamos

No fue todo recto. Acá están los "¿ah, eso pasaba?" que resolvimos.

API que crasheaba al arrancar

Railway reportaba 502 antes de que pudiéramos ni respirar.

expand_more

¿Por qué pasaba?

Argon2 (para hashear contraseñas seguros) + boto3 (para hablar con R2) necesitan ~100MB combinados. Railway nos daba 256MB en el container. Insuficiente.

¿Cómo lo arreglamos?

Aumentamos RAM a 512MB, y pusimos validaciones de startup que fallan *ruidosamente* si falta algo. Si la BD no está, lo sabemos al arrancar, no 15 segundos después.

Avatares que no se subían a R2

El frontend no tenía permiso para hablar directamente con Cloudflare.

expand_more

¿Por qué pasaba?

CORS (reglas de quién puede hablar con quién). R2 necesitaba saber que contamelo.com.co y www.contamelo.com.co eran amigos. Y para usar custom domains, la DNS del dominio tenía que estar en Cloudflare, no en otra registradora.

¿Cómo lo arreglamos?

Configuramos CORS bucket por bucket. Y migramos todos los nameservers a Cloudflare. Hoy los avatares suben en milisegundos, sin que el servidor toque nada.

Contraseña comprometida sin que nos diéramos cuenta

Un endpoint genérico casi permite que alguien sobrescriba el hash de una contraseña.

expand_more

¿Por qué pasaba?

Endpoint genérico que aceptaba cualquier campo de UserCreate. Si alguien mandaba {password: "mi_clave_nueva"}, directamente sobrescribía el hash seguro guardado en BD.

¿Cómo lo arreglamos?

Whitelist por endpoint. Cada actualización tiene un schema específico que dice EXACTAMENTE qué campos son permitidos. Para cambiar contraseña, hay un caso de uso aislado que pide validación de la contraseña actual.

Refrescar la página en /login devolvía 404

Single Page App en Vercel, pero al refrescar la URL desaparecía.

expand_more

¿Por qué pasaba?

Vercel buscaba archivos reales en la carpeta /login/. Como es una SPA, solo existe index.html. 404.

¿Cómo lo arreglamos?

Rewrite en vercel.json: cualquier ruta desconocida → index.html. React Router controla la navegación desde ahí.

Frontend y backend desplegando sin coordinarse

Vercel y Railway despliegan el mismo commit en paralelo — nada garantizaba cuál terminaba primero.

expand_more

¿Por qué pasaba?

Son dos plataformas independientes que no se conocen entre sí. Si las pruebas automáticas arrancaban apenas una terminaba, corrían el riesgo de probar un frontend nuevo contra un backend todavía viejo (o al revés).

¿Cómo lo arreglamos?

El disparo de pruebas escucha el evento nativo de despliegue de GitHub (lo emiten ambas plataformas) y solo actúa cuando las dos confirmaron éxito para el mismo commit. Sin locks ni estado propio — la plataforma más rápida ve que falta la otra y espera en silencio; la más lenta dispara justo una vez.

Las pruebas automáticas nunca llegaban a la app

Aterrizaban en una pantalla de login de Vercel, no en Contámelo.

expand_more

¿Por qué pasaba?

Los ambientes de prueba están protegidos por un muro de autenticación de Vercel, para que nadie externo entre por accidente. El primer intento de saltarlo mandó la credencial en cada petición del navegador — incluyendo las que iban hacia la API, que vive en otro dominio y la rechazó por CORS, rompiendo el login real.

¿Cómo lo arreglamos?

Se cambió a una cookie que se setea una sola vez, en la primera visita, y queda limitada al dominio del frontend — nunca viaja hacia la API. El login vuelve a funcionar sin tocar nada de seguridad del backend.

Optimizaciones de rendimiento

A medida que Contámelo crece en datos y usuarios, la experiencia tiene que seguir sintiéndose instantánea. Estas son las decisiones técnicas que lo hacen posible.

Índices estratégicos en PostgreSQL

Las consultas de deudas, abonos y pagos programados son O(1) sin importar el volumen.

expand_more

¿Qué problema resolvía?

Sin índices, cada consulta hacía un sequential scan — recorría toda la tabla para encontrar los registros del usuario. Con pocas filas no se nota, pero a medida que crecen los datos, las queries se vuelven lentas de forma predecible.

¿Qué se hizo?

Se agregaron índices compuestos en las tablas de deudas, abonos y pagos programados, apuntando a las columnas que filtran por usuario + estado. Ahora el query planner va directo al dato sin recorrer filas irrelevantes.

Tecnología

PostgreSQL (Neon) — B-tree indexes con filtros compuestos.

Caché inteligente con TanStack Query

Cambiar de filtro o pantalla no dispara nuevas peticiones — los datos ya están en memoria.

expand_more

¿Qué problema resolvía?

Cada vez que el usuario cambiaba entre "Me deben" / "Le debo" o aplicaba un filtro, el frontend hacía un fetch nuevo al servidor. Eso significa spinners, latencia, y consumo innecesario de datos móviles.

¿Qué se hizo?

Se implementó TanStack Query como capa de caché client-side. Los datos se almacenan en memoria con query keys por filtro y se invalidan de forma inteligente en background. El usuario ve los datos al instante mientras TanStack refresca silenciosamente si están stale.

Tecnología

TanStack Query v5 — stale-while-revalidate pattern, query key factories, background refetch.

Scroll infinito con paginación offset/limit

Las listas cargan progresivamente — la app abre rápido sin importar cuántos registros tenga.

expand_more

¿Qué problema resolvía?

Antes, la app cargaba TODAS las deudas y avisos de una vez. Con pocos registros funciona, pero un usuario con 200+ deudas esperaba segundos mirando un spinner antes de ver algo.

¿Qué se hizo?

Se implementó paginación offset/limit en el backend y scroll infinito en el frontend usando useInfiniteQuery de TanStack Query. El frontend calcula el siguiente offset como offset + items.length. La primera página carga al instante y el resto se trae a medida que el usuario baja.

Tecnología

Backend: paginación offset/limit con SQLModel. Frontend: TanStack Query useInfiniteQuery + Intersection Observer para trigger automático.

Avisos con filtros y priorización

La pantalla de avisos abre con lo importante primero, con scroll infinito y filtros integrados.

expand_more

¿Qué problema resolvía?

Los avisos eran una lista plana sin filtrar — el usuario veía todo mezclado, leídos y no leídos, y tenía que buscar manualmente lo que importaba.

¿Qué se hizo?

Se agregaron filtros "Todos" / "Sin leer" con default en "Sin leer" para que lo relevante aparezca primero. Cada filtro tiene su propia query key, así que cambiar entre ellos es instantáneo gracias al caché de TanStack Query. El scroll infinito aplica también acá.

Tecnología

Frontend: TanStack Query con query keys segmentadas por filtro + useInfiniteQuery. Backend: filtro por read_at IS NULL para "Sin leer", con índice dedicado.

Cómo llega código a producción

Cada push a una rama dispara un deploy automático.

develop

Dev sandbox

arrow_forward

staging

Antes de producción

arrow_forward

main

Producción real

Cada push corre un chequeo de calidad (lint, tipos, tests) antes de que Railway despliegue. En develop y staging, las migraciones de base de datos pendientes se aplican solas como parte de ese mismo chequeo — nunca antes de que el código pase. En main el pipeline nunca aplica solo: detecta si falta algo y bloquea el despliegue hasta que lo confirmemos a mano.

Cómo sabemos que sigue funcionando

Desplegar no es el final del proceso. Después de cada cambio, algo tiene que confirmar que la app real sigue andando — sin que alguien tenga que probarla a mano.

Pruebas escaladas por riesgo, no las mismas en todos lados

Producción nunca corre una prueba que escriba un dato real.

expand_more

¿Qué problema resolvía?

Correr la misma batería de pruebas completa en todos los ambientes es lento y, en producción, directamente riesgoso — una prueba que crea y borra datos de mentira no tiene nada que hacer tocando la base real de usuarios.

¿Qué se hizo?

Tres niveles, uno por ambiente: en desarrollo, un chequeo rápido de que la app carga y el login funciona. En pre-producción, la batería completa contra los flujos críticos (contactos, deudas, pagos) en varios navegadores y dispositivos. En producción, solo pruebas de lectura — entrar, mirar el panel — sin crear ni modificar nada jamás.

Tecnología

Playwright + TypeScript, Page Object Model, repo de pruebas separado (contamelo-e2e-tests) disparado automáticamente desde el pipeline principal.

Un reporte que llega antes de que preguntes

Cada corrida de pruebas termina en un correo, sin que nadie tenga que ir a buscarlo.

expand_more

¿Qué problema resolvía?

Que las pruebas corran solas no sirve de mucho si nadie se entera del resultado sin entrar manualmente a revisar el historial de corridas — y menos aún si eso pasa justo cuando algo se rompió.

¿Qué se hizo?

Al terminar cada corrida (pase o falle) llega un correo con una tabla real por prueba: nombre, navegador, resultado, duración, y el error puntual si algo falló — no solo un "algo se rompió, andá a mirar". Un link directo lleva a la corrida completa en caso de necesitar ver la traza paso a paso de la falla.

Tecnología

Reporter JSON de Playwright parseado a HTML propio, enviado vía Resend. El envío nunca bloquea ni enmascara el resultado real de las pruebas, aunque el correo falle.