A backend REST API for submitting and approving employee expenses, built with Java 17 + Spring Boot 4. Employees submit expenses; managers approve or reject them — with JWT authentication, role-based access, and business rules enforced at the service layer.
This is a portfolio project built to demonstrate a realistic, well-layered Spring Boot backend: authentication, authorization, persistence, validation, and tests.
- 🔐 JWT authentication — register and log in; every protected request is authenticated by a stateless token.
- 👥 Two roles —
EMPLOYEE(submits expenses) andMANAGER(approves/rejects them). - 🧾 Expense management — submit an expense, list your own expenses.
- ✅ Approval workflow — expenses move
PENDING → APPROVED / REJECTED, driven by a manager. - 🛡️ Business rules with teeth — only managers can decide; you can't approve your own expense; a settled expense can't be re-decided.
- 🧪 Unit-tested business logic (JUnit 5 + Mockito).
- 📦 Clean layered architecture — controller → service → repository.
- 📖 Interactive API docs — Swagger UI (springdoc) at
/swagger-ui.html, with JWT auth built into the "Authorize" button. - 🖥️ Built-in web frontend — a zero-dependency HTML/CSS/JS app served by Spring Boot itself at
/: register, log in, submit expenses, and (as a manager) approve/reject from the browser.
| Layer | Technology |
|---|---|
| Language | Java 17 |
| Framework | Spring Boot 4 (Web MVC, Data JPA, Security, Validation) |
| Database | PostgreSQL |
| Auth | Spring Security + JWT (jjwt), BCrypt password hashing |
| API docs | springdoc-openapi (Swagger UI) |
| Frontend | Vanilla HTML/CSS/JS served from Spring Boot's static/ folder |
| Build | Maven (wrapper included) |
| Testing | JUnit 5, Mockito, AssertJ |
| Boilerplate reduction | Lombok |
The app follows the classic Spring layering — each layer has one job:
HTTP request
│
▼
Controller ── receives the request, validates input, returns the response
│
▼
Service ── business logic and rules (e.g. "you can't approve your own expense")
│
▼
Repository ── reads/writes the database (Spring Data JPA)
│
▼
PostgreSQL
A JwtAuthenticationFilter runs before the controllers: it reads the Authorization: Bearer <token> header, validates the token, and tells Spring Security who the caller is.
- Java 17+
- PostgreSQL running locally (default port
5432)
CREATE DATABASE expense_tracker;The app runs out of the box against a local PostgreSQL using sensible defaults. To override them (recommended in any real environment), set environment variables:
| Variable | Default |
|---|---|
DB_URL |
jdbc:postgresql://localhost:5432/expense_tracker |
DB_USERNAME |
postgres |
DB_PASSWORD |
postgres |
JWT_SECRET |
a local-dev placeholder (set a real one in production) |
JWT_EXPIRATION_MS |
86400000 (24h) |
./mvnw spring-boot:run # macOS/Linux
.\mvnw.cmd spring-boot:run # WindowsThe API starts on http://localhost:8080. Verify it's up:
curl http://localhost:8080/api/health # -> OKWith the app running, open http://localhost:8080/ for a small built-in web UI — no separate frontend server, no build step, no framework. Spring Boot serves plain HTML/CSS/JS straight from src/main/resources/static/.
- Register / log in — the returned JWT is kept in
localStorage, so a page refresh keeps you signed in. - Employees submit expenses and watch their status change.
- Managers additionally get a pending-approvals queue with Approve/Reject buttons — including friendly error messages when a business rule blocks the action (e.g. deciding your own expense).
Because the frontend is served by the same app that hosts the API, there's no CORS configuration and deploying the backend deploys the UI with it.
With the app running, open http://localhost:8080/swagger-ui.html for interactive documentation of every endpoint — you can try them straight from the browser:
- Call
POST /api/auth/registerorPOST /api/auth/loginand copy thetokenfrom the response. - Click the Authorize 🔓 button (top right), paste the token, and confirm.
- Every protected endpoint now works from the UI — no curl or Postman needed.
The raw OpenAPI 3.1 spec is served at /v3/api-docs (useful for generating clients or importing into Postman).
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/api/health |
Public | Health check, returns OK |
POST |
/api/auth/register |
Public | Create an account, returns a token |
POST |
/api/auth/login |
Public | Log in, returns a token |
POST |
/api/expenses |
Any user | Submit an expense |
GET |
/api/expenses |
Any user | List your own expenses |
GET |
/api/expenses/pending |
Manager | List all pending expenses |
POST |
/api/expenses/{id}/approve |
Manager | Approve an expense |
POST |
/api/expenses/{id}/reject |
Manager | Reject an expense |
# 1. Register an employee (also returns a token)
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"secret123","role":"EMPLOYEE"}'
# 2. Log in to get a token
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"secret123"}' | jq -r .token)
# 3. Submit an expense (note the Bearer token)
curl -X POST http://localhost:8080/api/expenses \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"description":"Taxi to airport","amount":40.00,"category":"Travel"}'
# 4. List your own expenses
curl http://localhost:8080/api/expenses -H "Authorization: Bearer $TOKEN"
# 5. As a MANAGER: approve expense #1
curl -X POST http://localhost:8080/api/expenses/1/approve \
-H "Authorization: Bearer $MANAGER_TOKEN"Errors return clean JSON with the appropriate status:
| Status | When |
|---|---|
400 |
Validation failed (e.g. blank description, non-positive amount) — includes a per-field message map |
401 |
Missing/invalid token, or wrong login credentials |
403 |
Authenticated but not allowed (e.g. employee tries to approve; manager tries to approve their own) |
409 |
Trying to decide an expense that's already settled |
Business rules are covered by fast unit tests (no database or web server required — repositories are mocked):
./mvnw testExpenseServiceTest verifies the approval rules: approve/reject transitions, self-approval is forbidden, and settled expenses can't be re-decided.
- DTOs, not entities, at the boundary — requests/responses use dedicated record types (
CreateExpenseRequest,ExpenseResponse) so the database model never leaks to clients. BigDecimalfor money — neverdouble, to avoid floating-point rounding on currency.- Rules live in the service — data-dependent rules ("not your own", "still pending") are in
ExpenseService, where they're unit-tested; coarse role checks are declarative via@PreAuthorize. - Stateless JWT auth — no server-side sessions; each request proves itself with its token.
- Passwords hashed with BCrypt — plain-text passwords are never stored.
- Pagination on the expense lists
- Flyway/Liquibase migrations instead of
ddl-auto: update - Refresh tokens and token revocation
- A Docker Compose setup for one-command Postgres + app
- Integration tests with Testcontainers
This project is available under the MIT License.