App full stack para gestionar gastos compartidos entre grupos de personas (roommates, viajes, etc.), con cálculo automático de balances y simplificación de deudas.
- Backend: Java 21, Spring Boot 3, Spring Data JPA, Spring Security + JWT, PostgreSQL, Maven, Lombok
- Frontend: React 18 + Vite 8, TypeScript, TailwindCSS, React Query, React Router 7
- Infra: Docker + Docker Compose
Ya tienes Docker y VS Code. Necesitas instalar además:
Windows: descarga el instalador desde https://adoptium.net/ (elige Temurin 21 LTS) y sigue el wizard.
Mac (con Homebrew):
brew install openjdk@21Linux (Ubuntu/Debian):
sudo apt update
sudo apt install openjdk-21-jdkVerifica la instalación:
java -versionDeberías ver algo como openjdk version "21...".
Mac:
brew install mavenLinux:
sudo apt install mavenWindows: descarga desde https://maven.apache.org/download.cgi y agrega la carpeta bin al PATH.
Verifica:
mvn -versionNota: el repo ya incluye el Maven Wrapper, asi que no necesitas instalar Maven globalmente. Desde
backend/usa./mvnw(Linux/Mac) omvnw.cmd(Windows) en lugar demvn.
Vite 8 requiere Node ^20.19.0 || >=22.12.0. Recomendado: Node 22 LTS.
Descarga desde https://nodejs.org/ (elige la versión LTS) o usa un gestor de versiones como nvm:
nvm install 22
nvm use 22Verifica:
node -v
npm -vAbre VS Code, ve a la pestaña de extensiones (Ctrl+Shift+X) e instala:
- Extension Pack for Java (Microsoft)
- Spring Boot Extension Pack (VMware/Microsoft)
- ES7+ React/Redux/React-Native snippets
- Tailwind CSS IntelliSense
- Docker (Microsoft)
Nada arranca sin esto. JWT_SECRET no tiene valor por defecto y la
aplicación se niega a arrancar si falta o si mide menos de 32 bytes. Es
deliberado: un secreto con valor por defecto acaba en producción.
cp .env.example .envY rellena las dos variables sin valor:
| Variable | Cómo generarla |
|---|---|
DB_PASSWORD |
La que quieras; es tu Postgres local. |
JWT_SECRET |
openssl rand -base64 48 |
DB_PORT viene en 5434, no en 5432, y es a propósito. Ver
Conflicto de puerto en el 5432. Solo afecta
al lado del host: dentro de Compose el backend habla con postgres:5432.
.env está en .gitignore y nunca debe versionarse.
Desde la raíz del proyecto:
docker compose up --buildLevanta PostgreSQL, backend (:8080) y frontend (:5173).
docker compose down # detener
docker compose down -v # detener y BORRAR los datosEs la forma cómoda: recarga en caliente en ambos lados.
1. Solo la base de datos:
docker compose up postgres -d2. Backend — en una terminal, desde backend/. Hay que cargar el .env
antes, o fallará por falta de JWT_SECRET:
# Git Bash
set -a && . /c/splitwise/.env && set +a
./mvnw spring-boot:run# PowerShell
Get-Content ..\.env | Where-Object { $_ -match '^[A-Z]' } | ForEach-Object {
$n, $v = $_ -split '=', 2; Set-Item -Path "env:$n" -Value $v
}
.\mvnw spring-boot:runUsa el wrapper (./mvnw), no mvn: fija la versión de Maven y la de la
API de Docker que necesitan los tests.
3. Frontend — en otra terminal, desde frontend/:
cp .env.example .env # solo la primera vez
npm install
npm run devEn http://localhost:5173.
Los dos tienen que estar levantados a la vez. El frontend guarda el access token en memoria y recupera la sesión pidiéndole uno nuevo al backend; sin backend, todo acaba en la pantalla de acceso.
- Abre
http://localhost:5173. Sin sesión te lleva a/login. - Crea una cuenta en Crear una → entras al dashboard.
- Recarga la página (F5). Debes seguir dentro. Ese es el criterio de la
Fase 5: el access token vive en memoria y se perdió, pero la cookie
HttpOnlydel refresh token sobrevive y la sesión se recupera sola. - Menú de usuario → Cerrar sesión → vuelves a
/login.
Swagger en http://localhost:8080/swagger-ui.html (solo en el perfil dev;
en producción está apagado a propósito).
curl -i -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"name":"Ana Test","email":"ana@test.com","password":"password123"}'Responde 201 con {accessToken, expiresIn, userId, name, email} y una
cabecera Set-Cookie: refresh_token=...; HttpOnly.
curl -s -c /tmp/jar -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"ana@test.com","password":"password123"}'
curl -i -b /tmp/jar -X POST http://localhost:8080/api/auth/refresh # sin cuerpoPara llamar al resto de la API, pega el accessToken en Authorization:
curl http://localhost:8080/api/groups -H "Authorization: Bearer <accessToken>"El esquema lo gobierna Flyway, no Hibernate. Los scripts viven en
backend/src/main/resources/db/migration/ y se aplican solos al arrancar.
spring.jpa.hibernate.ddl-auto está en validate: la aplicación se niega a
arrancar si las entidades y las tablas han divergido. Eso convierte un error
silencioso en un fallo inmediato y visible.
Para cambiar el esquema se añade una migración nueva (V2__...sql). Nunca
se edita una ya aplicada: Flyway guarda su hash y aborta si cambia.
Si tienes un PostgreSQL instalado de forma nativa (en Windows es habitual, como
servicio postgresql-x64-NN), ocupará el 5432 y tus conexiones irán a él en vez
de al contenedor, con un desconcertante password authentication failed.
Comprueba quién escucha:
netstat -ano | grep ":5432" # Linux / Git Bash
Get-NetTCPConnection -LocalPort 5432 # PowerShellSolución: en tu .env, usa otro puerto para el contenedor.
DB_PORT=5434Solo cambia el puerto del lado del host. Dentro de Docker Compose el backend
sigue hablando con postgres:5432 por la red interna, así que no hay que tocar
nada más.
No hay panel de administración ni usuarios semilla: las cuentas se crean desde la propia aplicación, y hay dos caminos.
POST /api/auth/register, o el formulario en /register. Email único
(normalizado a minúsculas: Ana@x.com y ana@x.com son la misma cuenta) y
contraseña de 8 caracteres como mínimo, guardada con BCrypt.
Quien se registra así no pertenece a ningún grupo: crea el suyo o espera una invitación.
Un miembro genera un link (POST /api/groups/{id}/invitations) y quien lo
abre se registra y entra al grupo en una sola petición
(/register con invitationToken). En una sola por atomicidad: con dos
llamadas, un fallo entre ambas deja al usuario registrado y fuera del grupo.
Los links son de un solo uso y caducan a los 7 días. Para invitar a tres personas se generan tres links. Si se indica un email al crearlo, solo esa dirección puede aceptarlo.
En el frontend, /register?invitation=<token> ya arrastra el token.
Son por grupo, no globales: se puede ser administrador de uno y miembro raso de otro.
| Puede | |
|---|---|
| MEMBER | Ver el grupo, registrar gastos, registrar y confirmar pagos suyos |
| ADMIN | Todo lo anterior, más editar el grupo, invitar, expulsar y cambiar roles |
Quien crea un grupo es su administrador. Dos reglas que la API impone y no se pueden saltar:
- Un grupo nunca se queda sin administrador. Si pudiera, nadie podría invitar, expulsar ni editarlo: quedaría congelado sin vía de recuperación. El último miembro sí puede salir, porque ya no hay a quien dejar huérfano.
- Nadie sale de un grupo con saldo distinto de cero. Los balances se construyen a partir de la lista de miembros; si alguien con deuda deja de serlo, sus gastos siguen en la base pero desaparecen del informe y los balances de los que quedan dejan de sumar cero. El dinero se evaporaría.
GET /api/users/me— perfilPATCH /api/users/me— cambiar el nombre (el email no se cambia por esta vía)POST /api/users/me/password— cambiar la contraseña, exigiendo la actual
Cambiar la contraseña revoca todas las sesiones abiertas, incluida la propia. Quien la cambia suele hacerlo porque sospecha que alguien más tiene acceso; si las sesiones sobrevivieran, el intruso conservaría un refresh token válido durante treinta días.
No hay endpoint, y es una decisión pendiente, no un olvido. Borrar a alguien que aparece en gastos y liquidaciones rompería el histórico contable del grupo. Lo que hará falta es un borrado lógico que conserve los apuntes. Para pruebas, se limpia por SQL contra la base de desarrollo.
Dos credenciales con responsabilidades distintas:
| Vida | Naturaleza | Revocable | |
|---|---|---|---|
| Access token | 15 min | JWT, sin estado | No |
| Refresh token | 30 días | Valor opaco, con estado en BD | Sí |
Dónde vive cada uno en el cliente: el access token, en memoria (se
pierde al recargar, y es lo esperado). El refresh token, en una cookie
HttpOnly acotada a /api/auth que ningún script puede leer.
Guardar el access token en memoria y el refresh en localStorage sería
seguridad de escaparate: lo que un XSS se lleva de ahí no es una credencial de
15 minutos, sino una de 30 días y renovable. Por eso AuthResponse no
expone refreshToken, y /auth/refresh y /auth/logout van sin cuerpo.
Si escribes un cliente propio, Axios necesita
withCredentials: trueofetchnecesitacredentials: 'include'. Sin eso la cookie no viaja entre orígenes distintos y todo refresco falla con 401, con el síntoma desconcertante de que la sesión se cae exactamente a los 15 minutos.
El access token no se puede revocar, y por eso vive poco: su validez es el tiempo máximo que sobrevive una credencial robada. La capacidad de cortar una sesión vive en el refresh token.
Rotación. Cada refresco invalida el token presentado y emite uno nuevo dentro de la misma familia. Si alguna vez se presenta un token ya rotado, significa que existe una copia en circulación: se revoca la familia entera. No se puede distinguir a la víctima del atacante, así que se corta el acceso a ambos, y la víctima detecta el problema al verse obligada a entrar de nuevo (RFC 9700).
En la base solo se guarda el SHA-256 del token, nunca el token.
/api/auth/login y /api/auth/register están limitados por IP y por email
a la vez, porque cada dimensión cubre un ataque que la otra deja pasar: solo
por IP, una botnet prueba miles de contraseñas contra una cuenta; solo por
email, una sola máquina prueba una contraseña habitual contra miles de cuentas
(password spraying).
Configurable con RATE_LIMIT_LOGIN_ATTEMPTS, RATE_LIMIT_LOGIN_WINDOW_MINUTES
y sus equivalentes de registro.
Limitación conocida: el estado vive en memoria, así que con varias instancias cada una aplica su propio límite y el efectivo se multiplica por el número de réplicas. Para escalar horizontalmente hay que mover los buckets a Redis (
bucket4j-redis), sin cambiar el resto del diseño.
Detrás de un proxy inverso hay que configurar
server.forward-headers-strategy=FRAMEWORK. La IP se toma degetRemoteAddr()y no deX-Forwarded-Forleída a mano: esa cabecera la envía el cliente y puede falsificarse, con lo que bastaría rotarla para saltarse el límite.
cd backend
./mvnw clean test # suite completa (355 tests)
./mvnw clean test -Dtest=NombreTestclean. La extensión de Java de VS Code compila dentro de
target/ con su procesador Lombok roto y deja ahí .class marcados como
Unresolved compilation problems. Maven los da por buenos y ./mvnw test
falla con errores de compilación inventados sobre código que está perfecto. Es
además lo que MapStruct necesita para regenerar los mappers.
Frontend, con Vitest + Testing Library sobre jsdom:
cd frontend
npm test # suite completa (49 tests)
npm run test:watch # en vigilancia, mientras desarrollas
npx tsc --noEmit # tipos
npm run lint
npm run buildLos tests de frontend no hablan con el backend: eso se verifica en un navegador real contra el servidor real. Aquí se cubre la lógica que un recorrido por la interfaz no distingue —que una suma cuadre al céntimo, que un gasto no quede fechado mañana, que la guarda de ruta no expulse a nadie mientras comprueba la sesión— y que además sería lenta y frágil de comprobar a mano.
Los tests de integración levantan un PostgreSQL 16 real con Testcontainers,
no H2. H2 acepta SQL que PostgreSQL rechaza y no implementa igual NUMERIC ni
las palabras reservadas: dejaría pasar migraciones que fallan en producción.
Requiere Docker en marcha. El pom.xml fija docker.api.version porque los
daemon recientes (Docker 25+, MinAPIVersion 1.44) rechazan con HTTP 400 la
versión de API que docker-java negocia por defecto, y Testcontainers reporta
entonces un engañoso "Could not find a valid Docker environment". Si tu Docker
es más antiguo, ajústalo:
./mvnw test -Ddocker.api.version=1.43splitwise-clone/
├── backend/
│ ├── src/main/java/com/expensesplit/
│ │ ├── config/ Spring Security, CORS
│ │ ├── controller/ Endpoints REST
│ │ ├── service/ Lógica de negocio (incluye el algoritmo de deudas)
│ │ ├── repository/ Interfaces JPA
│ │ ├── model/ Entidades (User, Group, Expense, etc.)
│ │ ├── dto/ Objetos de transferencia (request/response)
│ │ ├── security/ JWT, filtros de autenticación
│ │ └── exception/ Manejo global de errores
│ └── src/test/ Tests unitarios (incluye el test del algoritmo de deudas)
├── frontend/
│ └── src/
│ ├── api/ Llamadas a la API con Axios
│ ├── pages/ Páginas (Login, Register, Dashboard)
│ ├── routes/ Configuración de rutas
│ └── ...
└── docker-compose.yml
El plan por fases está en PLAN.md. Fases 0-7 completadas.
- ✅ Modelo de datos, migraciones Flyway (7) y 355 tests con PostgreSQL real
- ✅ Autenticación de nivel producto: rotación de refresh tokens con detección
de reutilización, cookie
HttpOnly, rate limiting por IP y por email - ✅ Grupos, roles, invitaciones por link de un solo uso
- ✅ Gastos con los cuatro modos de reparto (EQUAL, EXACT, PERCENTAGE, SHARES), categorías, filtros y paginación
- ✅ Balances con desglose y liquidaciones con confirmación
- ✅ Simplificación de deudas al mínimo de transacciones
- ✅ OpenAPI/Swagger
- ✅ Capa de API tipada con renovación transparente del token
- ✅ Sesión, rutas protegidas y pantallas de acceso
- ✅ Layout, componentes base y estados vacíos
- ✅ Listado y detalle de grupos, con el saldo propio en cada uno
- ✅ Crear grupo, invitar por link y pantalla pública de aceptación
- ✅ Gastos con filtros, scroll infinito y los cuatro modos de reparto
- ✅ Balances con desglose, liquidaciones sugeridas y confirmación de pagos
- ✅ Analítica del gasto por categoría y por mes
- ✅ 49 tests con Vitest + Testing Library
- ⬜ Fase 8 — Producción: CI, imagen de producción con nginx, observabilidad, backups y despliegue
- Rate limiting en memoria. Con varias réplicas el límite efectivo se multiplica. Escalarlo es mover los buckets a Redis.
purgeExpired()no lo llama nadie. Existe enRefreshTokenServiceeInvitationService, pero falta la tarea programada: ambas tablas crecen indefinidamente.- Códigos de creación inconsistentes.
POST /groupsyPOST /groups/{id}/expensesdevuelven 200;/auth/register,/invitationsy/settlementsdevuelven 201. El cliente no ramifica sobre ello, así que unificarlo sigue siendo un cambio de una línea. - Sin borrado de usuarios, por lo dicho más arriba.
SecurityConfigavisa al arrancar sobre elAuthenticationManagerglobal; funciona, pero conviene limpiarlo.