Skip to content
PG
All projects

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

fincore · preview
Demo data. FinCore overview: net worth, income and spending for the month, latest transactions and budgets in progress.
Demo data. The FinCore overview at 390 px wide.

Features

Implemented

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.

Demo data. Transactions list with filters by account, category, dates and type.
Implemented

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.

Demo data. Transfer form between accounts with source, destination, amount, date and description.
Implemented

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

Implemented

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.

Demo data. Monthly budgets by category with current spending, the limit and the percentage used.
Partial

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.

Demo data. List of system and custom categories.
Partial

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.

Demo data. FinCore API in Swagger UI, with endpoint groups for accounts, transactions, budgets and analytics.

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.

FinCore architectureThe React web app calls nginx, which forwards /api to the Spring Boot application. The application is a modular monolith: each module has domain, application and infrastructure layers, and modules notify each other with events. Data lives in PostgreSQL with Flyway migrations.ClientWeb React 19TanStack Query · ZodHTTPEdgenginxsingle origin/api/v1Spring Boot 3Modular monolith16 modules · eventsinfrastructure/webapplicationdomainSpring SecurityJWT · rate limitJPA · SQLDataPostgreSQL 16pessimistic locksFlyway23 migrations
  • domain holds entities and rules that do not depend on Spring (for example, the state transitions of an account or a bill).
  • application holds use cases, events and business exceptions; infrastructure holds 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 Money type that holds amount and currency, scales to each currency’s decimals with HALF_EVEN and 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
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
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-Key header: 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
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
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 BudgetThresholdReachedEvent and 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

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 to httpOnly cookies 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.