Guía para trabajar en este repositorio: comandos, arquitectura y las trampas que ya costaron tiempo. Pensada para asistentes de código, pero sirve igual para una persona que llega al proyecto.
Vaquita: app autoalojada para dividir gastos entre amigos, privada, pensada para un grupo chico en Argentina. Web solamente (responsive, sin app nativa). Registro cerrado por invitación. La UI, las rutas y los comentarios del código están en español rioplatense — mantené ese registro.
docker compose -f docker-compose.dev.yml up -d # Postgres local en el 5433
# (docker-compose.yml es el stack completo, para desplegar)
npm run dev # servidor de desarrollo
npm run build # prisma generate + next build. Es el chequeo de tipos del proyecto.
npm run db:migrate # crear y aplicar una migración a partir del schema
npm run db:deploy # aplicar migraciones pendientes (lo que corre el contenedor al arrancar)
npm run db:studio # explorar la base
npm run db:seed # datos de ejemplo — BORRA TODA LA BASE antes de cargar
npm run marca # regenera los archivos de marca desde brand/No hay tests ni linter configurados. npm run build es la única verificación automática:
corre TypeScript sobre todo el proyecto y falla ante cualquier error de tipos. Corrélo antes de
dar por terminado un cambio.
Para verificar comportamiento, levantá la app y probá el flujo real. El seed deja tres grupos con
gastos, pagos y comentarios; se entra con el email de BOOTSTRAP_ADMIN_EMAIL y la contraseña
vaquita1234.
La plata son enteros en centavos (bigint), nunca floats. Las columnas de importe son BigInt
en Prisma. Todo reparto pasa por lib/money.ts (splitEvenly, allocateByWeights), que usan el
método del resto mayor para que la suma de las partes cierre exacto con el total — el centavo
sobrante va determinísticamente a los primeros. Si tocás reparto, la invariante a preservar es esa.
Saldo ≠ consumo. El saldo (net) es lo que te deben menos lo que debés; el consumo es la suma
de tus partes. Se pueden haber gastado millones y estar en cero.
Los bigint no cruzan a componentes de cliente. Serializalos como string y reconstruilos del
otro lado (ver app/(app)/grupos/[groupId]/incluir/).
Next.js App Router con Server Components y Server Actions. No hay API REST ni capa de cliente de datos: las páginas leen con Prisma y los formularios postean a Server Actions.
El cálculo de saldos es el corazón y vive en lib/balances.ts, todo en funciones puras:
netBalances()— saldo neto de cada uno a partir de gastos y pagos.pairwiseDebts()— deudas reales par a par: la parte de cada participante se reparte entre quienes pagaron, en proporción a lo que puso cada uno.simplifyDebts()— greedy de mínimo flujo sobre los saldos netos.settlementPlan()— elige entre 2 y 3 según el flagsimplifyDebtsdel grupo.
lib/queries.ts es la capa de lectura: loadLedgers() trae los movimientos crudos y el resto
compone sobre eso. Cualquier pantalla que muestre saldos debe pasar por settlementPlan(), no
recalcular por su cuenta.
Autorización. No hay middleware. Cada página de grupo verifica membresía y hace notFound()
si no sos miembro (404, no "sin permiso": no confirma que el grupo exista). Cada Server Action
revalida la membresía en el servidor antes de escribir — el chequeo de la página no alcanza,
porque las acciones son endpoints HTTP invocables a mano. Ser admin no da acceso al contenido de
grupos ajenos, sólo a /admin.
Sesiones (lib/auth.ts): token aleatorio en cookie httpOnly; en la base se guarda su
HMAC-SHA256 con AUTH_SECRET, nunca el token. Cambiar AUTH_SECRET invalida todas las sesiones y
todos los links de reset.
Invitaciones (lib/invites.ts, app/invitacion/): un link puede ser de un solo uso o
ilimitado (maxUses: null, para mandar a un grupo de WhatsApp). El consumo de usos va con
compare-and-swap sobre useCount para que dos personas simultáneas no se pasen del límite.
/invitacion/[code] es una página (necesita metadata para la vista previa de WhatsApp) que
redirige a /invitacion/entrar, un route handler que hace la escritura.
Cosas que ya costaron tiempo y conviene no volver a descubrir:
- Tailwind v4 no permite
@applyde clases propias. Englobals.csslas variantes de botón repiten la base en un selector agrupado en vez de aplicar.btn. - TypeScript está fijado en 6. La 7 no expone la API de compilador que necesita Next 16 y el dev server muere al arrancar.
- Prisma 7: el cliente se genera en
lib/generated/prisma/(gitignoreado) y se instancia con el adapterPrismaPg. Después de tocar el schema hay que reiniciar el dev server, no alcanza conprisma generate: el proceso tiene el cliente viejo en memoria. - Nada de
window.confirm. Los navegadores embebidos lo descartan solo y eso cuenta como cancelar, así que el botón queda mudo. Usá la propconfirmdeSubmitButton, que arma la pregunta en línea. - En route handlers, devolvé
Locationrelativo. Detrás de un reverse proxy la URL del request es la interna del contenedor (0.0.0.0:3000); armar una URL absoluta con ella manda al navegador a una dirección inexistente. - La imagen de vista previa no renderiza emojis (el generador de Next no trae fuente de emoji): se dibujan como cuadraditos. Usá formas y texto.
- Las fechas nunca se formatean con el reloj del proceso. Todas las funciones de
lib/dates.tsreciben la zona explícita, que sale degetTimezone()(lib/settings.ts) y se configura desde/admin. Así cambiarla tiene efecto inmediato y el resultado no depende de dónde corra el contenedor;TZsólo se usa como valor inicial. Las fechas por defecto de los formularios se calculan en el servidor y se pasan por prop, para que no difieran entre SSR e hidratación. Las fechas de formulario se guardan al mediodía de la zona elegida: la medianoche puede correrse de día en el salto de horario de verano. - El seed es destructivo. Nunca contra producción.
Los originales de la vaca viven en brand/ y no se sirven: de ahí se derivan con
npm run marca las versiones de public/marca/ (WebP para pantalla), los íconos y el PNG que usa
la imagen de vista previa. Si cambia un original, hay que correr ese script.
En la barra, la vaca arranca pegada al borde de arriba y sobresale sólo hacia abajo: la barra está fija en el tope de la ventana, así que todo lo que suba por encima se recorta.
El favicon no es la ilustración. A 16px el dibujo con degradados y sombras se vuelve una mancha
marrón, así que brand/icono-simple.svg es una versión aparte con las formas mínimas que
sobreviven a ese tamaño: silueta con orejas, una mancha negra grande y el hocico rosa. La
ilustración sí se usa en los íconos grandes (apple-touch de 180px y los del manifest), donde el
detalle suma.
Las capturas del README están en docs/ y se sacan a mano con el navegador, no se generan solas:
si cambia bastante la interfaz, conviene rehacerlas. El banner sí lo regenera npm run marca.
La imagen de vista previa lee el PNG del disco y lo embebe como data URI — el renderer corre aislado y no resuelve rutas del sitio. Tampoco lee WebP ni dibuja emojis.
- Rutas y textos en español (
/grupos,/gastos,/saldar,/configuracion). - Nombre de pila en listas apretadas (
lib/names.ts, que desambigua con la inicial del apellido si se repite); nombre completo en perfil, integrantes, admin y detalle de gasto. - Componentes de servidor por defecto;
"use client"sólo donde hay estado o interacción. - Los formularios con estado usan
useActionStatey devuelven{ error?, success? }. - Mensajes de error escritos para una persona, no para un desarrollador: "Faltan asignar $1.200 para llegar al total", no "validation failed".
Coolify construye el Dockerfile (build pack Dockerfile, puerto 3000). El entrypoint corre
prisma migrate deploy antes de levantar el server, así que las migraciones se aplican solas en
cada deploy. Healthcheck en /api/salud. Variables requeridas: DATABASE_URL, AUTH_SECRET,
APP_URL, BOOTSTRAP_ADMIN_EMAIL. El push a main dispara deploy automático.