MoviFit
Reservas de clases y membresías para estudios de movimiento
Reservas, membresías por créditos y pagos para estudios de pilates, yoga y barre. Pura Vida Moves, un estudio boutique en Guanacaste, opera con él 25 clases semanales y 61 alumnos matriculados, con las reglas de negocio aplicadas en PostgreSQL y probadas con pgTAP.
Pantallas
Stack tecnológico
Visión general
Pura Vida Moves es un estudio boutique de movimiento en Guanacaste, Costa Rica: 16 clases, 25 bloques semanales, 8 instructores, adultos y niños. La matrícula, los pagos y la asistencia se llevaban por WhatsApp y a mano. MoviFit reemplazó eso. Los alumnos reservan clases y compran paquetes desde el teléfono, los encargados gestionan las clases de sus hijos, los instructores pasan lista, una tablet en la entrada funciona como kiosco de check-in y el estudio tiene su propio sitio público. Empecé el repositorio a finales de agosto de 2026, y en cuestión de semanas el estudio ya operaba reservas y pagos reales con él.
⚡ El desafío
Un crédito de clase es dinero. Los alumnos compran paquetes que vencen, regalan créditos a amigos, cancelan tarde o no llegan, y cada uno de esos casos tiene que poder explicársele a un cliente meses después. Dos personas pueden intentar tomar el último cupo en el mismo segundo. Una tablet compartida en la recepción debe permitir que cualquiera marque asistencia sin exponer nada de nadie. Y el sitio público nunca debe convertirse en una forma de leer la base de datos.
✓ La solución
Primero escribí la arquitectura como ADRs, 14 hasta ahora, cada uno con la alternativa que descarté. El navegador solo lee y llama funciones: cada escritura es un RPC de PostgreSQL, así las reglas, las transacciones y la bitácora de auditoría viven en un solo lugar. Los créditos son un ledger append-only; reservar retiene un crédito y asistir lo consume; el último cupo se decide con un único UPDATE condicional en la base de datos. Todo se prueba donde vive, con pgTAP, y la carrera por el cupo se prueba con diez conexiones en paralelo peleando por un asiento.
Impacto y resultados
En Pura Vida Moves, reservando clases y gestionando membresías mensuales desde la app
16 clases y 8 instructores en dos programas
55 archivos de tests de base de datos en CI, junto a Vitest y Playwright
Decisiones clave
SPA en React, no Next.js (ADR-001)
La lógica de negocio vive en Postgres, así que lo único que Next.js aportaría es SSR para SEO, al costo de server y client components y capas de caché. El SEO se fue a un sitio aparte en Astro.
Los créditos son un ledger, no un contador (ADR-002)
El saldo es la suma de las filas no vencidas, y se gasta primero lo que vence primero. Nunca se edita ni se borra nada: una corrección es una fila nueva. Hay triggers que bloquean UPDATE y DELETE, y un trigger a nivel de sentencia que bloquea TRUNCATE, que los triggers por fila nunca ven.
Reservar retiene el crédito, asistir lo gasta (ADR-006)
Una cancelación tardía o una ausencia solo cuesta una clase si la clase estaba llena, porque es cuando dejó a alguien más por fuera. La carrera por el último cupo se resuelve con un UPDATE ... WHERE reserved_count < capacity, no en React.
El cliente lee; escribir es llamar una función (ADR-011)
Row-level security responde qué fila, no qué columna. Una auditoría encontró que un permiso de UPDATE sobre toda la tabla dejaba a un cliente mover a un alumno a otra organización. El permiso se redujo, y ahora cualquier insert, update o delete en el frontend tiene que justificarse en el pull request.
Un kiosco con un rol mínimo (ADR-008)
La tablet de la recepción guarda un token de dispositivo, no una sesión de administrador. Puede hacer exactamente tres cosas: listar las sesiones de hoy, buscar alumnos activos por nombre e insertar una fila de asistencia. Se reinicia a los 10 segundos para que la siguiente persona nunca vea el nombre anterior.
El sitio público ve una sola vista (ADR-013)
Cada estudio tiene un sitio en su propio subdominio, renderizado con Astro en Cloudflare Workers. Solo puede leer una vista de Postgres y cuatro funciones públicas, así que la pregunta de qué puede ver el sitio público se responde en una pantalla. Una caída de la base de datos devuelve 503, nunca 404, para que Google no dé de baja al estudio.
Destacados técnicos
- ▸ React 19, Vite, TanStack Router y TanStack Query, Tailwind v4, PWA instalable
- ▸ 128 migraciones; RPCs SECURITY DEFINER con EXECUTE revocado a PUBLIC, después de medir que un grant a authenticated no hacía nada por dejar fuera a los anónimos
- ▸ pgTAP con 55 archivos y 851 aserciones, Vitest para unidades y Playwright de punta a punta pasando por el login real con magic link leído de un buzón local
- ▸ Un solo job de CI ordenado por lo que sale más barato descubrir mal: escaneo de secretos, typecheck, lint, tests unitarios, luego reset de la base desde las migraciones, pgTAP, el test de la carrera por el cupo, diff de los tipos generados, tests end-to-end, build y un escaneo del bundle compilado en busca de llaves filtradas
- ▸ Cinco jobs de pg_cron (generación de sesiones a 21 días, vencimiento de membresías, cierre de sesiones terminadas, avisos de vencimiento, envío de correos) y cuatro Edge Functions en Deno
- ▸ Cuentas de encargado y dependiente con un consentimiento firmado por alumno y un permiso específico para gestionar pagos
Aprendizajes clave
Una alumna escribió que le habían rebajado una clase dos veces, una como ausencia y otra como asistencia. El arreglo fue una fila nueva en el ledger que devuelve el crédito en la misma transacción, más una migración idempotente que le repuso el crédito a todos los ya afectados, demostrada corriéndola dos veces en un test.
Di EXECUTE a authenticated y asumí que los anónimos quedaban fuera. No era así: Postgres da EXECUTE a PUBLIC por defecto, y dos de mis tres arreglos solo parecían haber funcionado. Los privilegios se miden, no se asumen.
Un correo de confirmación salió con un link roto. Los logs de la función mostraron que la versión que atendía producción nunca se había construido desde el repositorio. Todo lo que se despliega a mano termina contradiciendo a git.
El link para registrar el kiosco solo se podía copiar en la computadora de la administradora, nunca en la tablet al otro lado del salón. El único camino que funcionaba era el de mi test end-to-end. Un código QR lo resolvió.
Escribir la alternativa descartada junto a cada decisión es lo que permite revisar esa decisión un mes después.