A REST API for managing personal expenses, built with Java 17 + Spring Boot 3.5. Expenses are persisted to a local JSON file — no database required.
Bonus feature implemented: OpenAPI 3 / Swagger UI documentation.
| Capability | Endpoint |
|---|---|
| Add an expense | POST /api/expenses |
| View all expenses | GET /api/expenses |
| Filter expenses by category | GET /api/expenses?category=food |
| Total expenses (overall and by category) | GET /api/expenses/summary |
| Total for one category | GET /api/expenses/summary?category=food |
| Fetch a single expense | GET /api/expenses/{id} |
| Delete an expense | DELETE /api/expenses/{id} |
| Interactive API docs (bonus) | GET /swagger-ui.html |
- JDK 17 or newer — that is the only requirement.
- Maven is not required. The repo ships with the Maven Wrapper (
mvnw), which downloads the correct Maven version automatically on first run.
Verify your JDK:
java -versionThis downloads every dependency and compiles the project.
macOS / Linux:
./mvnw -B clean package -DskipTestsWindows (PowerShell or cmd):
.\mvnw.cmd -B clean package -DskipTests
The first run downloads Maven and the Spring Boot dependencies, so it takes a couple of minutes. Subsequent runs are fast.
macOS / Linux:
./mvnw spring-boot:runWindows:
.\mvnw.cmd spring-boot:run
The server starts on http://localhost:8080.
Alternatively, run the packaged jar produced by the install step above:
java -jar target/smart-expense-tracker-api-1.0.0.jarTo use a different port or data file:
java -jar target/smart-expense-tracker-api-1.0.0.jar --server.port=9090 --expense.data-file=/tmp/expenses.jsonmacOS / Linux:
./mvnw -B testWindows:
.\mvnw.cmd -B test
71 tests across three suites. They use a temporary data file, so running them never
touches your real data/expenses.json.
| Suite | Tests | Covers |
|---|---|---|
ExpenseApiIntegrationTest |
32 | Full HTTP layer: routing, JSON binding, validation, error mapping, persistence |
ExpenseServiceTest |
27 | Business rules: normalisation, filtering, money arithmetic, totals |
JsonFileExpenseRepositoryTest |
12 | File persistence: restart durability, corrupt files, concurrent writes |
curl -X POST http://localhost:8080/api/expenses \
-H "Content-Type: application/json" \
-d '{"title":"Groceries at BigBasket","amount":1250.50,"category":"Food","date":"2026-07-15"}'201 Created, with a Location header pointing at the new resource:
{
"id": "fb2803ba-bb1d-46cd-be65-02e03128dd4c",
"title": "Groceries at BigBasket",
"amount": 1250.50,
"category": "food",
"date": "2026-07-15",
"createdAt": "2026-07-31T10:16:14.823686900Z"
}The id is always server-generated; an id supplied by the client is ignored.
Field rules
| Field | Rules |
|---|---|
title |
required, non-blank, max 200 chars, trimmed |
amount |
required, > 0, at most 2 decimal places |
category |
required, non-blank, max 100 chars, stored lowercase |
date |
required, ISO yyyy-MM-dd, must be a real calendar date |
curl http://localhost:8080/api/expensesReturns a JSON array, newest expense date first.
curl "http://localhost:8080/api/expenses?category=food"Case-insensitive — food, Food and FOOD all match. An unknown category returns [].
curl http://localhost:8080/api/expenses/summary{
"total": 6650.80,
"count": 4,
"byCategory": {
"travel": 5400.00,
"food": 1250.80
}
}byCategory is ordered highest-spend first. Add ?category=travel to narrow both the
total and the breakdown to a single category.
curl -X DELETE http://localhost:8080/api/expenses/{id}204 No Content on success, 404 if the id is unknown.
Every failure returns the same shape:
{
"timestamp": "2026-07-31T10:16:42.270193500Z",
"status": 400,
"error": "Bad Request",
"message": "Validation failed",
"path": "/api/expenses",
"fieldErrors": {
"amount": "amount must be greater than 0",
"date": "date is required"
}
}fieldErrors is present only for validation failures, and reports every invalid
field at once rather than failing on the first one.
With the server running:
- Swagger UI — http://localhost:8080/swagger-ui.html
- OpenAPI 3 JSON — http://localhost:8080/v3/api-docs
.
├── README.md
├── AI_NOTES.md
├── pom.xml
├── mvnw / mvnw.cmd # Maven Wrapper — no local Maven install needed
├── src/
│ └── main/
│ ├── java/com/expensetracker/
│ │ ├── ExpenseTrackerApplication.java
│ │ ├── controller/ExpenseController.java
│ │ ├── service/ExpenseService.java # business rules
│ │ ├── repository/ # storage abstraction + JSON file impl
│ │ ├── model/Expense.java
│ │ ├── dto/ # request/response shapes
│ │ ├── exception/ # error model + global handler
│ │ └── config/OpenApiConfig.java
│ └── resources/application.yml
├── tests/
│ └── java/com/expensetracker/
│ ├── ExpenseApiIntegrationTest.java
│ ├── service/ExpenseServiceTest.java
│ └── repository/
│ ├── JsonFileExpenseRepositoryTest.java
│ └── InMemoryExpenseRepository.java # test double
└── data/expenses.json # created on first write; git-ignored
Note on the layout: the assignment asks for top-level
src/andtests/directories. Maven's default issrc/test/java, sopom.xmlexplicitly pointstestSourceDirectoryattests/javato match the requested structure.
Money is BigDecimal, never double. Repeated double addition drifts —
0.10 + 0.20 is 0.30000000000000004. Amounts are normalised to a scale of 2 with
RoundingMode.HALF_UP on the way in, and every total is computed and returned at scale
2. There is a test asserting exactly this case.
Categories are normalised to lowercase. Otherwise Food and food become two
separate buckets in the totals, which is almost never what a user means. Filtering is
therefore case-insensitive for free.
Storage is a JSON file, loaded into memory at startup. Reads are served from a
LinkedHashMap; every mutation rewrites the file. For a personal expense tracker the
dataset is small enough that this is simpler and faster than an append log.
- A
ReentrantReadWriteLockguards the map so two concurrent requests cannot interleave a read-modify-write. - Writes go to a temp file in the same directory and are then atomically moved into place, so a crash mid-write cannot leave a half-written, unparseable store behind.
- If the data file exists but is corrupt, the app fails to start rather than silently starting empty and overwriting the user's history on the next write.
ExpenseRepository is an interface. The service layer is unit-tested against an
in-memory fake, and swapping the JSON file for a real database would not touch a single
line of business logic.
Dates are parsed strictly. 2026-02-31 is rejected rather than being clamped to the
28th — see AI_NOTES.md, this was a real bug the tests caught.
JAVA_HOME is set to an invalid directory — JAVA_HOME must point at the JDK root,
not its bin folder:
# macOS / Linux
export JAVA_HOME=/path/to/jdk-17
# Windows PowerShell
$env:JAVA_HOME='C:\Program Files\Java\jdk-17'Port 8080 already in use — start on another port:
./mvnw spring-boot:run -Dspring-boot.run.arguments=--server.port=9090Reset the stored data — stop the server and delete data/expenses.json.