FinCore
Accounts, transactions, transfers and budgets on a Spring Boot API with money in BigDecimal and end-to-end idempotency.
In 30 seconds
- Category
- Full stack · Personal finance
- Status
- Complete API · the web app covers daily use · no public demo
- Stack
- Java 21 · Spring Boot 3 · PostgreSQL 16 · Flyway · React 19
- Tests
- 150 integration tests with Testcontainers + 72 unit test methods + 15 frontend tests
- Architecture
- Modular monolith · 16 modules · Spring events between modules
- Web app scope
- Sign-up, overview, accounts, transactions, transfers, budgets and categories. The rest is used through the API (Swagger).
- Live demo
- No public demo
Overview
- What it is
- A personal finance application with a very complete REST API (accounts, transactions, transfers, budgets, goals, subscriptions, bills, CSV import, multi-currency and analytics) and a React web app for daily use.
- The problem it solves
- Keeping your own accounts requires exact balances and nothing recorded twice, even when the network fails or the same button is pressed twice.
- Who it is for
- Someone who wants to keep track of their accounts, spending by category and budgets.
- Goal
- Financial logic that is correct by design: exact money, atomic operations, repeated requests with no effect and a history that is never rewritten.
Preview


Features
Accounts and transactions
Bank, card, cash or savings accounts with their currency and states (active, archived, closed) with allowed transitions. Income and expenses update the balance, with search, filters and sorting.
Why it matters: Filters live in the URL: reloading or sharing the link shows the same list.

Transfers between accounts
A transfer creates both legs (outgoing and incoming) and updates both balances in a single transaction.
Why it matters: One account is never debited without the other being credited.

Correcting without rewriting history
The backend never edits transactions, it only voids them. Correcting one from the web app creates the right version as a new transaction and voids the original, which stays visible as voided.
Why it matters: You can always see what changed and when.
Pending assetPending screenshot: corrected transaction and voided original
Budgets with alerts
Budgets per category and period that alert at 80 % and 100 %. The alert is raised as an event picked up by the notifications module.
Why it matters: Knowing you are close to the limit before you pass it.

Categories and automatic rules
System and custom categories with subcategories. The API also has rules that categorise transactions automatically; the web app only covers categories.
Why it matters: Spending is grouped without classifying each transaction by hand.

Features available only through the API
Savings goals, subscriptions and bills, CSV statement import with duplicate detection, multi-currency with historical exchange rates and detailed analytics. They are implemented and tested in the backend, but the web app does not show them yet.
Why it matters: The API is documented in Swagger and can be used in full from there.

Gallery
Pending assetPending screenshots: voiding and correcting a transaction
Pending assetPending video: transfer between two accounts
Stack
Backend
- Java 21 + Spring Boot 3.3
- REST API (Web, Data JPA, Validation, Actuator)
- Spring Security + jjwt + BCrypt
- Authentication and sessions
- springdoc-openapi
- Swagger UI documentation
- Apache Commons CSV
- Statement import
- Micrometer + Prometheus
- Business and application metrics
Database
- PostgreSQL 16
- Data and analytics aggregations in SQL
- Flyway
- 23 versioned migrations; Hibernate only validates
Frontend
- React 19 + Vite + TypeScript
- Web app for daily use
- TanStack Query + React Router
- Server data and navigation
- React Hook Form + Zod
- Forms
- Tailwind CSS 4
- Styling; own components with native
<dialog>
Testing
- JUnit 5 + Spring Boot Test
- Unit (Surefire) and integration (Failsafe) tests
- Testcontainers
- A real PostgreSQL in integration tests
- Vitest
- Frontend logic
Infrastructure
- Docker + Docker Compose
- PostgreSQL, API and web app
- nginx
- Serves the web app and forwards /api: one origin, no CORS
- GitHub Actions
mvnw verify, lint, tests, build and Docker images
Architecture
A modular monolith: one project organised by business area. Each module is split into domain, application and infrastructure, and side effects travel as events.
domainholds entities and rules that do not depend on Spring (for example, the state transitions of an account or a bill).applicationholds use cases, events and business exceptions;infrastructureholds REST controllers, DTOs and repositories.- Modules notify each other with Spring events: budgets publishes that a threshold was reached and notifications picks it up without budgets knowing it exists.
- The web app talks to a single origin: Vite in development and nginx in Docker forward /api to the backend.
Engineering decisions
Each decision with the problem, the solution, the reason and the exact place in the code where it lives.
Money as BigDecimal with banker’s rounding
- Problem
- With
double, 0.1 + 0.2 is not 0.3, and adding amounts in different currencies produces a meaningless number. - Solution
- A
Moneytype that holds amount and currency, scales to each currency’s decimals withHALF_EVENand throws if two currencies are mixed. - Why
- The mistake is caught when the code is written, not in a balance that does not add up months later. Banker’s rounding avoids an upward bias over many roundings.
- Code
- Money.java:19 (opens in a new tab)src/main/java/com/fincore/shared/money/Money.java
- Test
“rejectsOperationsAcrossDifferentCurrencies”
MoneyTest.java:33 (opens in a new tab)
Transfers with ordered locking
- Problem
- Two crossed transfers (A→B and B→A) at the same time can block each other, and without locking two operations can overwrite each other’s balance.
- Solution
- The transfer is one transaction that locks both accounts with
PESSIMISTIC_WRITE, always sorted by UUID, before touching the balances. - Why
- Locking in the same order prevents a waiting cycle, and pessimistic locking guarantees nobody reads a balance that is about to change.
- Code
- AccountBalanceService.java:46 (opens in a new tab)src/main/java/com/fincore/accounts/application/AccountBalanceService.java
- TransferService.java:39 (opens in a new tab)src/main/java/com/fincore/transactions/application/TransferService.java
- Test
“crossedConcurrentTransfersDoNotDeadlockAndKeepTotals”
TransferControllerIT.java:112 (opens in a new tab)
End-to-end idempotency
- Problem
- With a bad connection, the client may resend the same request and record the same expense or transfer twice.
- Solution
- The
Idempotency-Keyheader: a second request with the same key replays the first response; the same key with different data is rejected. The web app generates the key when each form opens. - Why
- Retrying becomes safe, including when correcting a transaction: if voiding fails, it can be repeated without duplicating anything.
- Code
- IdempotencyService.java:48 (opens in a new tab)src/main/java/com/fincore/shared/idempotency/IdempotencyService.java
- TransactionForms.tsx:39 (opens in a new tab)frontend/src/pages/transactions/TransactionForms.tsx
- Test
“concurrentTransfersWithTheSameKeyExecuteOnlyOnce”
IdempotencyIT.java:147 (opens in a new tab)
History is never edited
- Problem
- If a transaction can be modified, yesterday’s balance can no longer be explained.
- Solution
- The backend only allows voiding and refunding. Voiding one leg of a transfer voids both, and refunds never exceed the original expense.
- Why
- That is how real accounting works: you correct by adding, not by deleting.
- Code
- TransactionCorrectionService.java:75 (opens in a new tab)src/main/java/com/fincore/transactions/application/TransactionCorrectionService.java
- Test
“concurrentRefundsNeverExceedTheExpense”
VoidAndRefundIT.java:117 (opens in a new tab)
Modules decoupled with events
- Problem
- If budgets called notifications directly, every new module that wanted to react would force a change in budgets.
- Solution
- Budgets publishes
BudgetThresholdReachedEventand notifications listens with@TransactionalEventListener(AFTER_COMMIT). - Why
- The alert is only created if the operation was committed, and modules can grow without knowing each other.
- Code
- BudgetAlertService.java:79 (opens in a new tab)src/main/java/com/fincore/budgets/application/BudgetAlertService.java
- BudgetNotificationListener.java:25 (opens in a new tab)src/main/java/com/fincore/notifications/application/BudgetNotificationListener.java
What is missing
- The web app does not cover goals, subscriptions, bills, CSV import, rules, sessions or detailed analytics: they are used through the API.
- Tokens are stored in
localStorage; moving tohttpOnlycookies would change the API contract. - Emails are written to the log; production would need an SMTP server.
- Rate limiting is kept in memory: with several instances, each would keep its own count.