FinCore
Cuentas, movimientos, transferencias y presupuestos sobre una API Spring Boot con dinero en BigDecimal e idempotencia de extremo a extremo.
En 30 segundos
- Categoría
- Full stack · Finanzas personales
- Estado
- API completa · la web cubre el uso diario · sin demo pública
- Stack
- Java 21 · Spring Boot 3 · PostgreSQL 16 · Flyway · React 19
- Tests
- 150 de integración con Testcontainers + 72 métodos unitarios + 15 del frontend
- Arquitectura
- Monolito modular · 16 módulos · eventos de Spring entre módulos
- Alcance de la web
- Registro, resumen, cuentas, movimientos, transferencias, presupuestos y categorías. El resto, desde la API (Swagger).
- Demo online
- Sin demo pública
Visión general
- Qué es
- Una aplicación de finanzas personales con una API REST muy completa (cuentas, movimientos, transferencias, presupuestos, objetivos, suscripciones, facturas, importación CSV, multimoneda y analítica) y una web en React para el uso diario.
- Qué problema resuelve
- Llevar las cuentas propias exige que los saldos sean exactos y que nada se registre dos veces, aunque falle la red o se pulse dos veces el mismo botón.
- Para quién
- Una persona que quiere controlar sus cuentas, su gasto por categorías y sus presupuestos.
- Objetivo
- Que la lógica financiera sea correcta por diseño: dinero exacto, operaciones atómicas, peticiones repetidas sin efecto y un historial que no se reescribe.
Vista previa


Funcionalidades
Cuentas y movimientos
Cuentas de banco, tarjeta, efectivo o ahorro con su moneda y estados (activa, archivada, cerrada) con transiciones permitidas. Ingresos y gastos que actualizan el saldo, con búsqueda, filtros y ordenación.
Para qué sirve: Los filtros se guardan en la URL: al recargar o compartir el enlace se ve la misma lista.

Transferencias entre cuentas
Una transferencia crea las dos patas (salida y entrada) y actualiza los dos saldos en una sola transacción.
Para qué sirve: Nunca queda una cuenta descontada y la otra sin abonar.

Corregir sin reescribir el historial
El backend no edita movimientos, solo los anula. Corregir uno desde la web crea la versión buena como movimiento nuevo y anula el original, que sigue visible como anulado.
Para qué sirve: Siempre se puede ver qué cambió y cuándo.
Recurso pendienteCaptura pendiente: movimiento corregido y original anulado
Presupuestos con avisos
Presupuestos por categoría y periodo que avisan al llegar al 80 % y al 100 %. El aviso se genera con un evento que recoge el módulo de notificaciones.
Para qué sirve: Saber que te acercas al límite antes de pasarlo.

Categorías y reglas automáticas
Categorías del sistema y propias, con subcategorías. La API tiene además reglas que categorizan los movimientos automáticamente; en la web solo están las categorías.
Para qué sirve: El gasto se agrupa sin clasificar cada movimiento a mano.

Funciones solo disponibles en la API
Objetivos de ahorro, suscripciones y facturas, importación de extractos CSV con detección de duplicados, multimoneda con tipos de cambio históricos y analítica detallada. Están implementadas y probadas en el backend, pero la web todavía no las muestra.
Para qué sirve: La API está documentada en Swagger y se puede usar completa desde ahí.

Galería
Recurso pendienteCapturas pendientes: anular y corregir un movimiento
Recurso pendienteVídeo pendiente: transferencia entre dos cuentas
Stack
Backend
- Java 21 + Spring Boot 3.3
- API REST (Web, Data JPA, Validation, Actuator)
- Spring Security + jjwt + BCrypt
- Autenticación y sesiones
- springdoc-openapi
- Documentación Swagger UI
- Apache Commons CSV
- Importación de extractos
- Micrometer + Prometheus
- Métricas de negocio y de la aplicación
Base de datos
- PostgreSQL 16
- Datos y agregaciones de la analítica en SQL
- Flyway
- 23 migraciones versionadas; Hibernate solo valida
Frontend
- React 19 + Vite + TypeScript
- Web para el uso diario
- TanStack Query + React Router
- Datos del servidor y navegación
- React Hook Form + Zod
- Formularios
- Tailwind CSS 4
- Estilos; componentes propios con
<dialog>nativo
Testing
- JUnit 5 + Spring Boot Test
- Tests unitarios (Surefire) y de integración (Failsafe)
- Testcontainers
- PostgreSQL real en los tests de integración
- Vitest
- Lógica del frontend
Infraestructura
- Docker + Docker Compose
- PostgreSQL, API y web
- nginx
- Sirve la web y reenvía /api: un solo origen, sin CORS
- GitHub Actions
mvnw verify, lint, tests, build e imágenes Docker
Arquitectura
Un monolito modular: un solo proyecto organizado por áreas de negocio. Cada módulo se divide en dominio, aplicación e infraestructura, y los efectos secundarios viajan como eventos.
domaincontiene entidades y reglas que no dependen de Spring (por ejemplo, las transiciones de estado de una cuenta o de una factura).applicationcontiene los casos de uso, los eventos y las excepciones de negocio;infrastructure, los controladores REST, los DTOs y los repositorios.- Los módulos se avisan con eventos de Spring: presupuestos publica que se superó un umbral y notificaciones lo recoge sin que presupuestos sepa que existe.
- La web habla con un solo origen: Vite en desarrollo y nginx en Docker reenvían /api al backend.
Decisiones técnicas
Cada decisión con el problema, la solución, el motivo y el lugar exacto del código donde está.
Dinero con BigDecimal y redondeo bancario
- Problema
- Con
double, 0,1 + 0,2 no da 0,3, y sumar importes de monedas distintas da un número sin sentido. - Solución
- Un tipo
Moneyque guarda importe y moneda, ajusta la escala a los decimales de cada moneda conHALF_EVENy lanza una excepción si se opera con monedas distintas. - Por qué
- El error se detecta al escribir el código, no en un saldo que no cuadra meses después. El redondeo bancario evita el sesgo hacia arriba al redondear muchos importes.
- Código
- Money.java:19 (se abre en una pestaña nueva)src/main/java/com/fincore/shared/money/Money.java
- Test
“rejectsOperationsAcrossDifferentCurrencies”
MoneyTest.java:33 (se abre en una pestaña nueva)
Transferencias con bloqueo ordenado
- Problema
- Dos transferencias cruzadas (A→B y B→A) a la vez pueden bloquearse mutuamente, y sin bloqueo dos operaciones pueden pisar el saldo de la otra.
- Solución
- La transferencia es una sola transacción que bloquea las dos cuentas con
PESSIMISTIC_WRITE, siempre ordenadas por su UUID, antes de tocar los saldos. - Por qué
- Bloquear en el mismo orden impide el ciclo de espera, y el bloqueo pesimista garantiza que nadie lee un saldo que está a punto de cambiar.
- Código
- AccountBalanceService.java:46 (se abre en una pestaña nueva)src/main/java/com/fincore/accounts/application/AccountBalanceService.java
- TransferService.java:39 (se abre en una pestaña nueva)src/main/java/com/fincore/transactions/application/TransferService.java
- Test
“crossedConcurrentTransfersDoNotDeadlockAndKeepTotals”
TransferControllerIT.java:112 (se abre en una pestaña nueva)
Idempotencia de extremo a extremo
- Problema
- Con mala cobertura, el cliente puede reenviar la misma petición y registrar el mismo gasto o la misma transferencia dos veces.
- Solución
- La cabecera
Idempotency-Key: la segunda petición con la misma clave devuelve la respuesta de la primera; con la misma clave y datos distintos se rechaza. La web genera la clave al abrir cada formulario. - Por qué
- Reintentar se vuelve seguro, también en la corrección de movimientos: si la anulación falla, se puede repetir sin duplicar nada.
- Código
- IdempotencyService.java:48 (se abre en una pestaña nueva)src/main/java/com/fincore/shared/idempotency/IdempotencyService.java
- TransactionForms.tsx:39 (se abre en una pestaña nueva)frontend/src/pages/transactions/TransactionForms.tsx
- Test
“concurrentTransfersWithTheSameKeyExecuteOnlyOnce”
IdempotencyIT.java:147 (se abre en una pestaña nueva)
El historial no se edita
- Problema
- Si un movimiento se puede modificar, el saldo de ayer deja de poder explicarse.
- Solución
- El backend solo permite anular y reembolsar. Anular una pata de una transferencia anula las dos, y los reembolsos nunca superan el gasto original.
- Por qué
- Es como funciona la contabilidad real: se corrige añadiendo, no borrando.
- Código
- TransactionCorrectionService.java:75 (se abre en una pestaña nueva)src/main/java/com/fincore/transactions/application/TransactionCorrectionService.java
- Test
“concurrentRefundsNeverExceedTheExpense”
VoidAndRefundIT.java:117 (se abre en una pestaña nueva)
Módulos desacoplados con eventos
- Problema
- Si presupuestos llamara directamente a notificaciones, cada módulo nuevo que quisiera reaccionar obligaría a tocar presupuestos.
- Solución
- Presupuestos publica
BudgetThresholdReachedEventy notificaciones lo escucha con@TransactionalEventListener(AFTER_COMMIT). - Por qué
- El aviso solo se crea si la operación se confirmó, y los módulos pueden crecer sin conocerse.
- Código
- BudgetAlertService.java:79 (se abre en una pestaña nueva)src/main/java/com/fincore/budgets/application/BudgetAlertService.java
- BudgetNotificationListener.java:25 (se abre en una pestaña nueva)src/main/java/com/fincore/notifications/application/BudgetNotificationListener.java
Lo que falta
- La web no cubre objetivos, suscripciones, facturas, importación CSV, reglas, sesiones ni la analítica detallada: se usan desde la API.
- Los tokens se guardan en
localStorage; pasar a cookieshttpOnlycambiaría el contrato de la API. - Los emails se escriben en el log; para producción haría falta un servidor SMTP.
- El límite de peticiones se guarda en memoria: con varias instancias, cada una llevaría su cuenta.