College Club Budget & Billing API

Build a Spring Boot REST API with role-based security and spending-cap enforcement.

Introduction

30 Second Summary

Every college club needs money for events, equipment, and competitions. Managing who gets how much, tracking what they spend, and stopping overspending is a real problem that software solves every day.

In this project, you will build a REST API using Spring Boot that handles the full lifecycle of club budget requests, approvals, and expense billing. The system enforces role-based security and a hard spending cap so clubs cannot overspend.

What You'll Build

Picture running a curl command to submit an expense bill and watching the API fire back a rejection because it would push the club over their approved spending limit.

By the end of this project, you'll have:

  • Submitted a budget request as a club and seen it stored with a PENDING status, ready for admin review.
  • Approved or rejected budget requests as an admin, while non-admin users get blocked with a 403 Forbidden.
  • Submitted expense bills against approved budgets and watched the spending cap reject a bill that would push spending over the approved amount.
  • Secret Mission: Add a budget utilization summary endpoint that shows each club's approved amount, total spent, and percentage utilized.

Are there any prerequisites?

A basic understanding of REST APIs (HTTP methods, JSON, status codes) is helpful. Familiarity with any programming language works. Java syntax will be explained as you go.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Development Environment

You need a Java runtime, a project scaffold, and an editor before you can write any API code.

In this step, you will install OpenJDK 17, generate a Spring Boot project using start.spring.io, and confirm the application starts on port 8080.

In this step, get ready to:
  • Install OpenJDK 17 and verify it with java -version.
  • Generate a Spring Boot project with Web, JPA, H2, and Security dependencies.
  • Run the application and confirm it responds on port 8080.
Install OpenJDK 17

The Java Development Kit (JDK) provides the compiler and runtime that Spring Boot needs.

  • Install OpenJDK 17 by running this command:
sudo apt install openjdk-17-jdk

What does this command do?

This installs the full Java 17 development kit from Ubuntu's package repository. It includes both the Java compiler (javac) and the runtime (java).

  • Confirm the installation by running:
java -version

Check the output in your terminal and find the tab below that matches what you see.

✔️ I see version 17 or higher

You should see output similar to:

openjdk version "17.0.x" ...

Java is ready. Move on to the next substep.

ⓧ I see an older version

Your system has a Java version below 17 installed. Spring Boot 4.1.0 requires at least Java 17.

  • Install OpenJDK 17 alongside your existing version by running:
sudo apt install openjdk-17-jdk
  • Set Java 17 as the active version by running:
sudo update-alternatives --config java
  • Select the entry for java-17-openjdk from the list.
  • Run java -version again and confirm it now shows version 17.

ⓧ Command not found

Java is not installed on your system yet.

  • Install OpenJDK 17 by running:
sudo apt install openjdk-17-jdk
  • Run java -version again and confirm it shows version 17.

Still seeing an error after installing?

Make sure the install command completed without errors. If you see a 'package not found' message, run sudo apt update first to refresh your package list, then try the install again.

Help me troubleshoot my Java installation.

Generate the Spring Boot project from start.spring.io

Spring Initializr is a web service that generates a ready-to-run project with your chosen dependencies already wired in.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Download the project by running this command:
curl https://start.spring.io/starter.zip \
  -d dependencies=web,data-jpa,h2,security \
  -d bootVersion=4.1.0 \
  -d groupId=com.example \
  -d artifactId=club-budget \
  -d name=club-budget \
  -d packageName=com.example.clubbudget \
  -d javaVersion=17 \
  -d baseDir=club-budget \
  -o club-budget.zip

What does this command do?

This downloads a pre-configured Spring Boot 4.1.0 project as a zip file. The -d flags set the project coordinates and add four key dependencies:

  • Spring Web for building REST endpoints.
  • Spring Data JPA for database access with Java objects.
  • H2 Database for an in-memory database that resets on restart.
  • Spring Security for authentication and authorization.
  • Unzip the downloaded file by running:
unzip club-budget.zip
  • Move into the project folder by running:
cd club-budget
  • Confirm the project files exist by running:
ls

You should see mvnw, pom.xml, and the src directory listed in the output.

Don't see these files?

If unzip gave a 'command not found' error, install it with sudo apt install unzip and try again. If the curl command failed, check your internet connection and re-run it.

Help me troubleshoot my Spring Boot project download.

  • Open VS Code on the project folder by running:
code .
  • In VS Code, press Ctrl+Shift+X to open the Extensions sidebar.
  • Type Extension Pack for Java in the search bar.
  • Click Install on the result by Microsoft (ID: vscjava.vscode-java-pack).

Why install this extension?

The Extension Pack for Java gives VS Code Java language support, debugging, test runners, and Maven integration. Without it, VS Code treats Java files as plain text with no syntax help.

Now configure the H2 database connection for the project.

  • In VS Code's Explorer sidebar, navigate to src/main/resources/application.properties and open it.
  • Replace the file's contents with the following configuration:
spring.application.name=club-budget
spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:clubbudget
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.hibernate.ddl-auto=create-drop

What does this configuration do?

  • spring.datasource.url creates a named in-memory database called clubbudget. It resets every time you restart the app.
  • spring.h2.console.enabled turns on a web-based database viewer (handy for debugging later).
  • spring.jpa.hibernate.ddl-auto=create-drop tells JPA to auto-create tables from your Java entities on startup and drop them on shutdown.
  • Save the file with Ctrl+S.
Run the application

The Maven wrapper (mvnw) is a script bundled with the project that downloads and runs the correct Maven version automatically. You do not need Maven installed globally.

  • Open a terminal in VS Code with Ctrl+` (backtick key).
  • Start the application by running:
./mvnw spring-boot:run

What should I expect?

The first time you run this, Maven downloads all project dependencies. This may take 1-3 minutes depending on your internet connection. Watch for the download progress in the terminal.

When it finishes, you will see log output scrolling past. Look for a line that says "Started ClubBudgetApplication" near the end. That means the server is running.

Before you test the server, predict what will happen. Spring Security is on the classpath, but you have not configured any users yet. What do you think curl will get back from the server?

  • Open a second terminal (click the + icon in the terminal panel) and test the server by running:
curl http://localhost:8080

You will see a 401 Unauthorized response. This is the correct result.

Why a 401?

Spring Security automatically protects every endpoint when it is on the classpath. Since you have not configured any users or login rules yet, all requests are rejected as unauthorized. This proves Security is active and working.

Not seeing Started ClubBudgetApplication?

  • If you see a permission denied error on ./mvnw, make it executable with chmod +x mvnw and try again.
  • If you see a compilation error, confirm java -version shows 17 or higher. Spring Boot 4.1.0 requires at least Java 17.
  • If port 8080 is already in use, stop whatever is using it or check for another terminal already running the app.

Help me troubleshoot my Spring Boot startup error.

✔️ Awesome, I've got everything!

Great. Double check you have saved application.properties and that your terminal shows "Started ClubBudgetApplication".

ⓧ I'd like to double check the full code

Here is the application.properties file you should have:

spring.application.name=club-budget
spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:clubbudget
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.hibernate.ddl-auto=create-drop

And the auto-generated ClubBudgetApplication.java (you should not have modified this):

package com.example.clubbudget;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ClubBudgetApplication {

    public static void main(String[] args) {
        SpringApplication.run(ClubBudgetApplication.class, args);
    }
}

Your development environment is ready and Spring Security is guarding the gates. Next up, you will build the budget request API so clubs can submit and retrieve funding requests.

Build the Budget Request API

Your Spring Boot application boots successfully, but it can't do anything useful yet. Clubs need a way to submit budget requests with a dollar amount and a justification for the funds.

In this step, you'll build a JPA entity to store budget data, a repository to access it, and a REST controller that lets clubs create and list budget requests. You'll also temporarily disable Spring Security so you can test the endpoints without authentication.

In this step, get ready to:
  • Define a BudgetRequest entity that stores budget data in the database.
  • Create a REST controller with POST and GET endpoints for budget requests.
  • Test the full flow with curl to confirm data is stored and retrieved.
Create the BudgetRequest entity and repository

A JPA entity is a Java class that maps directly to a database table. Each field becomes a column, and Spring handles all the SQL for you behind the scenes.

  • In VS Code, create a new file called BudgetRequest.java inside src/main/java/com/example/clubbudget/.
  • Paste the following code to define the entity class with its fields:
package com.example.clubbudget;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

// Maps this class to a database table called "budget_request"
@Entity
public class BudgetRequest {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String clubName;
    private double amount;
    private String justification;
    private String status = "PENDING";

    public BudgetRequest() {}

What does this code do?

  • @Entity tells Spring this class represents a database table. Each instance is a row.
  • @Id and @GeneratedValue make the id field an auto-incrementing primary key.
  • The status field defaults to "PENDING" so every new request starts in a pending state.
  • The empty constructor is required by JPA to create instances when loading data from the database.
  • Below the constructor, add the getters and setters that let Spring read and write each field:
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getClubName() { return clubName; }
    public void setClubName(String clubName) { this.clubName = clubName; }

    public double getAmount() { return amount; }
    public void setAmount(double amount) { this.amount = amount; }

    public String getJustification() { return justification; }
    public void setJustification(String justification) { this.justification = justification; }

    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
}

Why do we need getters and setters?

JPA uses these methods to populate entity fields when reading from the database and to extract values when writing. Spring also uses them to convert incoming JSON into entity objects automatically.

  • Save BudgetRequest.java.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm both chunks are present in order: the package declaration, imports, class header with fields, and empty constructor at the top, followed by the getters and setters, then the closing brace.

Now you need a repository interface so Spring knows how to query this entity.

  • Create a new file called BudgetRequestRepository.java in the same src/main/java/com/example/clubbudget/ folder.
  • Paste the following code:
package com.example.clubbudget;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

// Spring auto-implements this interface with standard CRUD operations
public interface BudgetRequestRepository extends JpaRepository<BudgetRequest, Long> {
    List<BudgetRequest> findByStatus(String status);
}

What does this code do?

By extending JpaRepository, Spring automatically provides methods like save(), findAll(), and findById() without you writing any SQL.

The findByStatus method is a custom query. Spring reads the method name and generates the SQL WHERE status = ? automatically.

  • Save BudgetRequestRepository.java.
  • Confirm both files compile by running the application:
./mvnw spring-boot:run

You should see Started ClubBudgetApplication in the terminal output, confirming your entity and repository are valid.

  • Press Ctrl+C to stop the application.

Seeing a compilation error?

  • Check that BudgetRequest.java has the closing brace } at the very end of the file after all the getters and setters.
  • Make sure the package line reads package com.example.clubbudget; in both files.
  • Confirm the import lines in BudgetRequest.java use jakarta.persistence (not javax.persistence).

Still stuck? Help me debug this compilation error in my Spring Boot entity

Create the BudgetRequestController

With the data layer ready, you need a REST controller to handle HTTP requests. But first, Spring Security is blocking all requests with a 401 by default. You'll create a temporary security configuration that permits everything.

  • Create a new file called SecurityConfig.java in src/main/java/com/example/clubbudget/.
  • Paste the following code to temporarily disable authentication:
package com.example.clubbudget;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

// Temporarily permits all requests so we can test the data layer
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
        return http.build();
    }
}

What does this code do?

  • @Configuration tells Spring this class defines beans (objects Spring manages).
  • The SecurityFilterChain bean overrides Spring Security's default "block everything" behavior.
  • csrf(csrf -> csrf.disable()) disables CSRF protection so curl requests work without a token.
  • anyRequest().permitAll() allows all HTTP requests without credentials. You'll replace this with proper role-based rules in the next step.
  • Save SecurityConfig.java.
  • Now create the controller. Create a new file called BudgetRequestController.java in the same folder.
  • Paste the class declaration and constructor:
package com.example.clubbudget;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/budget-requests")
public class BudgetRequestController {

    private final BudgetRequestRepository budgetRequestRepository;

    // Spring injects the repository automatically via constructor
    public BudgetRequestController(BudgetRequestRepository budgetRequestRepository) {
        this.budgetRequestRepository = budgetRequestRepository;
    }
  • Below the constructor, add the POST and GET endpoint methods, then close the class:
    // POST /api/budget-requests - creates a new budget request
    @PostMapping
    public ResponseEntity<BudgetRequest> createRequest(@RequestBody BudgetRequest request) {
        request.setStatus("PENDING");
        BudgetRequest saved = budgetRequestRepository.save(request);
        return ResponseEntity.ok(saved);
    }

    // GET /api/budget-requests - lists all budget requests
    @GetMapping
    public List<BudgetRequest> listRequests() {
        return budgetRequestRepository.findAll();
    }
}

What do these endpoints do?

  • @RestController marks this class as an HTTP endpoint handler. Spring routes requests to the matching method.
  • @RequestMapping("/api/budget-requests") sets the base URL path for all endpoints in this controller.
  • The @PostMapping method reads JSON from the request body, forces the status to PENDING, saves it to the database, and returns the saved object (including the auto-generated ID).
  • The @GetMapping method returns every budget request in the database as a JSON array.
  • Save BudgetRequestController.java.

✔️ Awesome, I've got everything!

Great. Make sure both code chunks are in the same file in order.

ⓧ I'd like to double check the full code

package com.example.clubbudget;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/budget-requests")
public class BudgetRequestController {

    private final BudgetRequestRepository budgetRequestRepository;

    public BudgetRequestController(BudgetRequestRepository budgetRequestRepository) {
        this.budgetRequestRepository = budgetRequestRepository;
    }

    @PostMapping
    public ResponseEntity<BudgetRequest> createRequest(@RequestBody BudgetRequest request) {
        request.setStatus("PENDING");
        BudgetRequest saved = budgetRequestRepository.save(request);
        return ResponseEntity.ok(saved);
    }

    @GetMapping
    public List<BudgetRequest> listRequests() {
        return budgetRequestRepository.findAll();
    }
}
Test with curl

Time to see the API in action. You'll start the application and use curl to create a budget request, then retrieve it.

  • Start the application by running:
./mvnw spring-boot:run

Wait until you see Started ClubBudgetApplication in the output.

  • Open a second terminal in the same club-budget directory.

Before you run the next command, what do you think will come back? A budget request with an auto-generated ID, or an error?

  • Submit a budget request by running:
curl -X POST http://localhost:8080/api/budget-requests -H "Content-Type: application/json" -d '{"clubName":"Chess Club","amount":5000,"justification":"Tournament fees"}'

You should see a JSON response with your request, including an auto-generated id and status set to PENDING.

  • Retrieve all budget requests by running:
curl http://localhost:8080/api/budget-requests

You should see a JSON array containing the Chess Club request you just created.

Getting a connection refused error?

  • Make sure the application is still running in your first terminal. Look for Started ClubBudgetApplication in the output.
  • Check that you're running curl from a separate terminal window, not the one running the app.
  • If you see a 404, double-check that your @RequestMapping path is /api/budget-requests in the controller.

Still stuck? Help me debug my Spring Boot curl request

Your data layer is working. You can create budget requests and retrieve them. Next up, you'll add role-based security so only admins can approve or reject these requests.

Add Admin Approval with Role-Based Security

Your budget request API works, but there is a glaring problem. Anyone can hit any endpoint without logging in.

In the real world, only a college admin should be able to approve or reject budget requests. This step adds authentication (who are you?) and authorization (what can you do?) using Spring Security with HTTP Basic credentials.

In this step, get ready to:
  • Configure two in-memory users with different roles (CLUB and ADMIN).
  • Add approve and reject endpoints restricted to the ADMIN role.
  • Prove that a club user cannot approve requests while an admin can.
Configure Spring Security with in-memory users

Right now your SecurityConfig.java permits all requests. You need to replace that open-door policy with real authentication rules and define who can log in.

  • In VS Code, open src/main/java/com/example/clubbudget/SecurityConfig.java.
  • Replace the entire contents of the file with this updated security filter chain:
package com.example.clubbudget;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> {
                auth.requestMatchers("/api/budget-requests/*/approve", "/api/budget-requests/*/reject").hasRole("ADMIN");
                auth.anyRequest().authenticated();
            })
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}

What does this security config do?

  • csrf(csrf -> csrf.disable()) disables CSRF protection since this is a stateless API tested with curl.
  • requestMatchers(...).hasRole("ADMIN") locks the approve and reject URLs so only users with the ADMIN role can reach them.
  • anyRequest().authenticated() requires a valid username and password for every other endpoint.
  • httpBasic(Customizer.withDefaults()) enables HTTP Basic authentication, which reads credentials from the Authorization header.

The filter chain defines the rules, but Spring Security also needs to know who the valid users are.

  • Add this userDetailsService method inside the SecurityConfig class, below the securityFilterChain method's closing brace:
    @Bean
    public UserDetailsService userDetailsService() {
        var club1 = User.withDefaultPasswordEncoder()
            .username("club1")
            .password("password")
            .roles("CLUB")
            .build();
        var admin = User.withDefaultPasswordEncoder()
            .username("admin")
            .password("admin")
            .roles("ADMIN")
            .build();
        return new InMemoryUserDetailsManager(club1, admin);
    }

What does this code do?

This bean defines two in-memory users that Spring Security recognizes. club1 has the CLUB role and admin has the ADMIN role.

User.withDefaultPasswordEncoder() is a convenience method for demos. It stores the password in plain text, which is fine for learning but should never be used in production.

  • Save SecurityConfig.java.
  • Stop the running application with Ctrl+C in your terminal, then restart it by running:
./mvnw spring-boot:run

Before you test, what do you think happens if you try the same curl command from the previous step without providing credentials?

  • Test that authentication is now required by running:
curl -i http://localhost:8080/api/budget-requests

You get a 401 Unauthorized response. Spring Security blocks the request because you did not provide credentials.

  • Now supply credentials with the -u flag by running:
curl -u club1:password http://localhost:8080/api/budget-requests

You see an empty JSON array [] because the H2 in-memory database resets on each restart. The important thing is that the request succeeded with a 200 OK.

Still seeing 401 even with credentials?

  • Double-check you typed -u club1:password (colon between username and password, no spaces).
  • Make sure the userDetailsService bean is inside the SecurityConfig class and has the @Bean annotation.
  • Confirm you restarted the application after saving. help me debug my 401 error with Spring Security HTTP Basic
Create the approval and rejection endpoints

The security rules reference /api/budget-requests/*/approve and /api/budget-requests/*/reject, but those endpoints do not exist yet. You need to add them to BudgetRequestController.

  • Open src/main/java/com/example/clubbudget/BudgetRequestController.java.
  • Find the listRequests() method. Add this getRequest method below it so you can look up a single budget request by ID:
    @GetMapping("/{id}")
    public ResponseEntity<BudgetRequest> getRequest(@PathVariable Long id) {
        return budgetRequestRepository.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

What does this endpoint do?

This maps GET requests to /api/budget-requests/{id} and returns a single budget request. If the ID does not exist, it returns a 404 Not Found.

  • Save the file, stop the app with Ctrl+C, and restart:
./mvnw spring-boot:run
  • Create a test budget request (since the database was cleared on restart) by running:
curl -u club1:password -X POST http://localhost:8080/api/budget-requests -H "Content-Type: application/json" -d '{"clubName":"Chess Club","amount":5000,"justification":"Tournament fees"}'
  • Verify the GET by ID endpoint works by running:
curl -u club1:password http://localhost:8080/api/budget-requests/1

You see the Chess Club budget request with "id":1 and "status":"PENDING".

Now add the approve and reject endpoints. These change the budget request's status when called.

  • Below the getRequest method you just added, paste these two methods:
    @PutMapping("/{id}/approve")
    public ResponseEntity<BudgetRequest> approveRequest(@PathVariable Long id) {
        return budgetRequestRepository.findById(id)
            .map(request -> {
                request.setStatus("APPROVED");
                budgetRequestRepository.save(request);
                return ResponseEntity.ok(request);
            })
            .orElse(ResponseEntity.notFound().build());
    }

    @PutMapping("/{id}/reject")
    public ResponseEntity<BudgetRequest> rejectRequest(@PathVariable Long id) {
        return budgetRequestRepository.findById(id)
            .map(request -> {
                request.setStatus("REJECTED");
                budgetRequestRepository.save(request);
                return ResponseEntity.ok(request);
            })
            .orElse(ResponseEntity.notFound().build());
    }

What do these endpoints do?

Each endpoint looks up the budget request by ID. If found, it updates the status to APPROVED or REJECTED and saves it back to the database.

The security filter chain you configured earlier ensures only users with the ADMIN role can reach these URLs. The controller itself does not check roles. Spring Security handles that before the request even arrives here.

  • Save BudgetRequestController.java.

Seeing a compilation error?

  • Make sure the @PutMapping and @PathVariable annotations are available. They come from the org.springframework.web.bind.annotation.* import that already exists at the top of the file.
  • Verify that both new methods are inside the BudgetRequestController class (before the final closing }).
  • help me fix a compilation error in my BudgetRequestController

✔️ Awesome, I've got everything!

Great. Double check you saved both SecurityConfig.java and BudgetRequestController.java.

ⓧ I'd like to double check the full code

Scroll up and confirm your files contain each of these sections, in order:

SecurityConfig.java:

  • Package declaration and all 8 imports (including Customizer, User, UserDetailsService, InMemoryUserDetailsManager).
  • The securityFilterChain bean with requestMatchers for approve/reject, anyRequest().authenticated(), and httpBasic.
  • The userDetailsService bean defining club1 (role CLUB) and admin (role ADMIN).

BudgetRequestController.java:

  • The existing createRequest (POST) and listRequests (GET) methods.
  • The new getRequest method (GET /{id}).
  • The new approveRequest method (PUT /{id}/approve).
  • The new rejectRequest method (PUT /{id}/reject).
Test role-based access

Time to prove that roles actually work. You will try to approve a budget request as a club user, watch it fail, then succeed as an admin.

  • Stop the app with Ctrl+C and restart it by running:
./mvnw spring-boot:run
  • Create a fresh budget request for testing by running:
curl -u club1:password -X POST http://localhost:8080/api/budget-requests -H "Content-Type: application/json" -d '{"clubName":"Chess Club","amount":5000,"justification":"Tournament fees"}'

You see the saved request with "id":1 and "status":"PENDING".

Now, what do you think happens when club1 tries to approve this request?

  • Attempt to approve the request as club1 by running:
curl -u club1:password -X PUT http://localhost:8080/api/budget-requests/1/approve

You get a 403 Forbidden response. Spring Security verified that club1 is a real user (authentication passed) but denied access because the CLUB role cannot reach the approve endpoint (authorization failed).

  • Now approve the request as the admin by running:
curl -u admin:admin -X PUT http://localhost:8080/api/budget-requests/1/approve

You see the budget request returned with "status":"APPROVED" and the approved amount of 5000. Role-based security is working.

What is the difference between 401 and 403?

A 401 Unauthorized means the server does not know who you are. You either sent no credentials or invalid ones.

A 403 Forbidden means the server knows exactly who you are, but you do not have permission to do what you asked. Authentication succeeded. Authorization failed.

Getting 404 instead of 403?

  • Confirm you created a budget request in this session (the database resets on each restart).
  • Check that your @PutMapping("/{id}/approve") annotation is spelled exactly like that, including the forward slashes.
  • Verify the requestMatchers pattern in SecurityConfig uses * (single asterisk) not **.
  • help me debug why my approve endpoint returns 404

Your API now enforces who can do what. Clubs can submit and view budget requests, but only an admin can approve or reject them. Next up, you will build the expense billing system so clubs can submit bills against their approved budgets.

Add Expense Billing (Without Validation)

In the last step, you locked down your API with role-based security. Only an admin can approve or reject budget requests, and every endpoint requires authentication.

Now clubs need a way to actually spend their approved budgets. In this step, you'll build the expense billing endpoint that lets clubs submit bills against an approved budget. You'll build it without any spending-cap validation first, so you can see exactly what happens when nothing stops a club from overspending.

In this step, get ready to:
  • Create an Expense entity and repository for storing bills.
  • Build an ExpenseController with POST and GET endpoints.
  • Discover a real overspend bug by submitting bills that exceed the approved budget.
Create the Expense entity and repository

Just like BudgetRequest represents a funding request in the database, you need an Expense entity to represent each bill a club submits against their approved budget.

  • Create a new file called Expense.java in src/main/java/com/example/clubbudget/ (the same folder as your other Java files). Paste the following code:
package com.example.clubbudget;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Expense {

    // Auto-generated unique identifier for each expense
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    // Links this expense to its parent budget request
    private Long budgetRequestId;
    private String description;
    private double amount;

    public Expense() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public Long getBudgetRequestId() { return budgetRequestId; }
    public void setBudgetRequestId(Long budgetRequestId) { this.budgetRequestId = budgetRequestId; }

    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }

    public double getAmount() { return amount; }
    public void setAmount(double amount) { this.amount = amount; }
}

What does this code do?

  • @Entity tells JPA to map this class to a database table called EXPENSE.
  • budgetRequestId connects each expense to the budget request it bills against. This is how you'll look up all expenses for a given budget.
  • The amount field stores how much this particular expense costs.
  • Now create the repository interface. Create a new file called ExpenseRepository.java in the same folder and paste this code:
package com.example.clubbudget;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

// Spring Data JPA auto-generates the database queries from method names
public interface ExpenseRepository extends JpaRepository<Expense, Long> {
    // Finds all expenses linked to a specific budget request
    List<Expense> findByBudgetRequestId(Long budgetRequestId);
}

What does this code do?

Spring Data JPA reads the method name findByBudgetRequestId and automatically generates a SQL query that filters expenses by their budgetRequestId column. You don't write any SQL yourself.

  • Save both Expense.java and ExpenseRepository.java.
  • Confirm the application still compiles by stopping your running server with Ctrl+C in the terminal and restarting it by running:
./mvnw spring-boot:run
  • You should see "Started ClubBudgetApplication" in the terminal output. This confirms JPA picked up your new entity and created the EXPENSE table in the H2 database.

Seeing a compilation error?

  • Check that Expense.java has the correct package declaration: package com.example.clubbudget; at the top.
  • Make sure you used jakarta.persistence in the imports, not javax.persistence. Spring Boot 4.1.0 uses the Jakarta namespace.
  • Confirm both files are in the same folder as BudgetRequest.java.

Help me fix this compilation error in my Spring Boot project.

Create the ExpenseController with a POST endpoint

The entity and repository handle storage. Now you need a controller that exposes HTTP endpoints so clubs can submit expense bills via curl.

  • Create a new file called ExpenseController.java in the same package folder. Start with the class structure and constructor:
package com.example.clubbudget;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

// Maps all endpoints under the expenses sub-resource of a budget request
@RestController
@RequestMapping("/api/budget-requests/{budgetRequestId}/expenses")
public class ExpenseController {

    private final ExpenseRepository expenseRepository;
    private final BudgetRequestRepository budgetRequestRepository;

    // Spring injects both repositories automatically
    public ExpenseController(ExpenseRepository expenseRepository,
                             BudgetRequestRepository budgetRequestRepository) {
        this.expenseRepository = expenseRepository;
        this.budgetRequestRepository = budgetRequestRepository;
    }

What does this code do?

  • The @RequestMapping nests expenses under a specific budget request using {budgetRequestId} in the URL. This means you always bill against a specific budget.
  • The constructor takes both repositories because creating an expense needs to verify the parent budget request exists.
  • Below the constructor (still inside the class), add the POST and GET endpoint methods:
    // Accepts a new expense bill against the specified budget request
    @PostMapping
    public ResponseEntity<?> createExpense(@PathVariable Long budgetRequestId,
                                           @RequestBody Expense expense) {
        var budgetRequest = budgetRequestRepository.findById(budgetRequestId);
        if (budgetRequest.isEmpty()) {
            return ResponseEntity.notFound().build();
        }

        expense.setBudgetRequestId(budgetRequestId);
        Expense saved = expenseRepository.save(expense);
        return ResponseEntity.ok(saved);
    }

    // Lists all expenses submitted against a budget request
    @GetMapping
    public List<Expense> listExpenses(@PathVariable Long budgetRequestId) {
        return expenseRepository.findByBudgetRequestId(budgetRequestId);
    }
}

What does this code do?

  • createExpense first checks that the budget request exists. If it doesn't, it returns a 404.
  • If the budget request exists, it saves the expense and returns it. Notice there is NO check on whether the expense amount exceeds the approved budget. The bill is accepted unconditionally.
  • listExpenses returns all expenses for a given budget request so you can see what's been billed so far.
  • Save ExpenseController.java.
  • Stop the server with Ctrl+C and restart it by running:
./mvnw spring-boot:run
  • Confirm you see "Started ClubBudgetApplication" with no errors.
  • Test the new endpoint by creating a budget request and submitting an expense. Open a new terminal and run:
curl -u club1:password -X POST http://localhost:8080/api/budget-requests -H "Content-Type: application/json" -d '{"clubName":"Chess Club","amount":5000,"justification":"Tournament fees"}'
  • You should see a JSON response with the budget request, including an id of 1 and status PENDING.
  • Approve it as admin by running:
curl -u admin:admin -X PUT http://localhost:8080/api/budget-requests/1/approve
  • You should see the request with status APPROVED and amount 5000.
  • Now submit a first expense bill by running:
curl -u club1:password -X POST http://localhost:8080/api/budget-requests/1/expenses -H "Content-Type: application/json" -d '{"description":"Registration fees","amount":3000}'
  • You should see a 200 response with the saved expense showing amount 3000. Your billing endpoint works.

Getting a 404 or 401?

  • A 401 means the credentials are wrong. Make sure you're using -u club1:password (not club1:club1).
  • A 404 means the budget request doesn't exist at ID 1. Since H2 is in-memory, restarting the server wipes all data. Re-run the create and approve commands above first.
  • Check that your URL includes /expenses at the end of the path.

Help me debug my expense billing endpoint.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three new files: Expense.java, ExpenseRepository.java, and ExpenseController.java.

ⓧ I'd like to double check the full code

Here is the complete Expense.java:

package com.example.clubbudget;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Expense {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long budgetRequestId;
    private String description;
    private double amount;

    public Expense() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public Long getBudgetRequestId() { return budgetRequestId; }
    public void setBudgetRequestId(Long budgetRequestId) { this.budgetRequestId = budgetRequestId; }

    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }

    public double getAmount() { return amount; }
    public void setAmount(double amount) { this.amount = amount; }
}

Here is the complete ExpenseRepository.java:

package com.example.clubbudget;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface ExpenseRepository extends JpaRepository<Expense, Long> {
    List<Expense> findByBudgetRequestId(Long budgetRequestId);
}

Here is the complete ExpenseController.java:

package com.example.clubbudget;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/budget-requests/{budgetRequestId}/expenses")
public class ExpenseController {

    private final ExpenseRepository expenseRepository;
    private final BudgetRequestRepository budgetRequestRepository;

    public ExpenseController(ExpenseRepository expenseRepository,
                             BudgetRequestRepository budgetRequestRepository) {
        this.expenseRepository = expenseRepository;
        this.budgetRequestRepository = budgetRequestRepository;
    }

    @PostMapping
    public ResponseEntity<?> createExpense(@PathVariable Long budgetRequestId,
                                           @RequestBody Expense expense) {
        var budgetRequest = budgetRequestRepository.findById(budgetRequestId);
        if (budgetRequest.isEmpty()) {
            return ResponseEntity.notFound().build();
        }

        expense.setBudgetRequestId(budgetRequestId);
        Expense saved = expenseRepository.save(expense);
        return ResponseEntity.ok(saved);
    }
    @GetMapping
    public List<Expense> listExpenses(@PathVariable Long budgetRequestId) {
        return expenseRepository.findByBudgetRequestId(budgetRequestId);
    }
}
Demonstrate the overspend bug

Your billing endpoint works. But there's a critical flaw hiding in it. The Chess Club's budget was approved for 5000, and you already billed 3000. What happens if you submit another bill for 4000? That would push total spending to 7000, well past the 5000 limit.

Before you run the next command, take a guess. Do you think the API will accept or reject a bill that pushes spending over the approved amount?

  • Submit a second expense bill that would overshoot the budget by running:
curl -u club1:password -X POST http://localhost:8080/api/budget-requests/1/expenses -H "Content-Type: application/json" -d '{"description":"Travel costs","amount":4000}'

You get a 200 OK. The API happily accepted the bill. The Chess Club has now billed 7000 against a 5000 budget, and nothing stopped it.

  • Verify the damage by listing all expenses for this budget request:
curl -u club1:password http://localhost:8080/api/budget-requests/1/expenses

You see both expenses: 3000 and 4000. Total spent: 7000. Approved budget: 5000. That's a 2000 overspend and the system did nothing about it.

Why build it broken on purpose?

This is exactly how bugs appear in real production systems. A developer builds the "happy path" (accept an expense, save it) and forgets to enforce the business constraint (don't exceed the budget). By seeing the flaw yourself, you understand precisely why the validation in the next step exists.

In the next step, you'll add spending-cap enforcement that blocks any expense that would push the total over the approved limit.

You've proven that the current billing endpoint has a serious flaw. Any club can spend far beyond their approved budget and nothing stops them. In the next step, you'll add the spending-cap validation that blocks any bill that would push the total over the limit.

Enforce the Spending Cap

In the previous step, you discovered a serious bug. Your API happily recorded expenses totaling 7000 against a budget approved for only 5000. In the real world, that kind of defect costs organizations real money.

This is the pattern of iterative development: build the naive version, observe it fail, then enforce the constraint. Now you will add validation logic that sums existing expenses and rejects any bill that would push the total over the approved amount.

In this step, get ready to:
  • Add spending cap validation that rejects any expense exceeding the remaining budget.
  • Handle edge cases so billing is blocked against non-approved budget requests.
  • Run the full end-to-end lifecycle to confirm the cap works.
Add spending cap validation logic

The fix requires three things: query all existing expenses for the budget request, sum their amounts, and reject the new expense if it would push the total over the approved limit.

  • In VS Code, open ExpenseController.java.
  • Inside the createExpense method, find this line:
expense.setBudgetRequestId(budgetRequestId);
  • Add the following validation code directly above that line:
// Get the budget request to check the approved amount
var request = budgetRequest.get();

// Sum all existing expenses for this budget request
List<Expense> existingExpenses = expenseRepository.findByBudgetRequestId(budgetRequestId);
double totalSpent = existingExpenses.stream().mapToDouble(Expense::getAmount).sum();
double remaining = request.getAmount() - totalSpent;

// Reject if this expense would push spending over the limit
if (expense.getAmount() > remaining) {
    return ResponseEntity.badRequest()
        .body("Expense of " + expense.getAmount() + " would exceed remaining budget of " + remaining);
}

What does this code do?

This validation enforces the spending cap in three steps:

  • findByBudgetRequestId pulls every expense already recorded against this budget request from the database.
  • The stream sums all those expense amounts into totalSpent, then subtracts from the approved amount to get remaining.
  • If the new expense would exceed what is left, the API returns a 400 Bad Request with a clear message showing the numbers.
  • Save ExpenseController.java.
  • Stop the running application with Ctrl+C in the terminal, then restart it by running:
./mvnw spring-boot:run
  • Once you see "Started ClubBudgetApplication" in the output, set up test data by creating and approving a budget request by running:
curl -u club1:password -X POST http://localhost:8080/api/budget-requests -H "Content-Type: application/json" -d '{"clubName":"Chess Club","amount":5000,"justification":"Tournament fees"}'
  • Approve the request as admin by running:
curl -u admin:admin -X PUT http://localhost:8080/api/budget-requests/1/approve
  • Now test the spending cap. Try to submit an expense of 6000 against a budget approved for only 5000 by running:
curl -u club1:password -X POST http://localhost:8080/api/budget-requests/1/expenses -H "Content-Type: application/json" -d '{"description":"Overspend attempt","amount":6000}'

You should see a 400 Bad Request with the message: Expense of 6000.0 would exceed remaining budget of 5000.0. The cap is working.

Still seeing 200 OK for the overspend?

  • Make sure you saved ExpenseController.java and restarted the application. Code changes require a restart.
  • Check that your new code is placed above the expense.setBudgetRequestId(budgetRequestId) line, not below it.
  • Confirm the budget request was approved. Run curl -u club1:password http://localhost:8080/api/budget-requests/1 and check that status shows APPROVED.

Why is my expense still being accepted even though it exceeds the budget?

Handle edge cases

The cap check assumes the budget request is approved. But what if someone tries to bill against a PENDING or REJECTED request? You need a guard that blocks billing unless the status is APPROVED.

  • In ExpenseController.java, find this line inside the createExpense method:
var request = budgetRequest.get();
  • Add the following status check directly below that line (above the List<Expense> existingExpenses line):
// Block billing unless the budget request is approved
if (!"APPROVED".equals(request.getStatus())) {
    return ResponseEntity.badRequest()
        .body("Cannot submit expenses against a budget request that is not APPROVED. Current status: " + request.getStatus());
}

What does this code do?

This guard rejects any expense submission if the budget request is still PENDING or has been REJECTED. The error message includes the current status so the caller knows exactly why billing was blocked.

  • Save ExpenseController.java.
  • Stop the application with Ctrl+C and restart it by running:
./mvnw spring-boot:run
  • Create a new budget request (this time do NOT approve it) by running:
curl -u club1:password -X POST http://localhost:8080/api/budget-requests -H "Content-Type: application/json" -d '{"clubName":"Drama Club","amount":3000,"justification":"Costumes"}'
  • Try to submit an expense against the still-PENDING request by running:
curl -u club1:password -X POST http://localhost:8080/api/budget-requests/1/expenses -H "Content-Type: application/json" -d '{"description":"Fabric","amount":500}'

You should see a 400 Bad Request with the message: Cannot submit expenses against a budget request that is not APPROVED. Current status: PENDING.

Expense was accepted against the PENDING request?

  • Make sure the status check is placed between var request = budgetRequest.get() and the List<Expense> existingExpenses line.
  • Check the comparison string. It must be "APPROVED" in uppercase, matching the status set during approval.

Why is my APPROVED status check not blocking expenses on PENDING requests?

Run the full end-to-end flow

Time to prove the complete lifecycle works. You will create a budget request, approve it, submit a bill that fits within the budget, and then attempt one that exceeds the remaining balance.

  • Stop the application with Ctrl+C and restart to clear the in-memory database by running:
./mvnw spring-boot:run
  • Create a budget request for 5000 by running:
curl -u club1:password -X POST http://localhost:8080/api/budget-requests -H "Content-Type: application/json" -d '{"clubName":"Chess Club","amount":5000,"justification":"Tournament fees"}'
  • Approve the request as admin by running:
curl -u admin:admin -X PUT http://localhost:8080/api/budget-requests/1/approve

You should see the request returned with status APPROVED and amount 5000.

  • Submit a bill for 3000 (within the budget) by running:
curl -u club1:password -X POST http://localhost:8080/api/budget-requests/1/expenses -H "Content-Type: application/json" -d '{"description":"Registration fees","amount":3000}'

This succeeds with a 200 OK. The remaining budget is now 2000.

Before you run the next command, predict: what do you think will happen when you try to bill 2500 against a remaining budget of 2000?

  • Submit a bill for 2500 (which would exceed the remaining 2000) by running:
curl -u club1:password -X POST http://localhost:8080/api/budget-requests/1/expenses -H "Content-Type: application/json" -d '{"description":"Travel expenses","amount":2500}'

You get a 400 Bad Request with the message: Expense of 2500.0 would exceed remaining budget of 2000.0. The spending cap is enforced. Compare this to Step 4 where both bills sailed through with no complaint.

Why build naive first, then fix?

This iterative approach mirrors professional development. You built the simplest version, observed it fail, and then added the constraint. This makes the validation logic feel purposeful instead of arbitrary.

In production codebases, many validation rules exist because someone once discovered the system allowed something it should not. The pattern is always the same: discover the gap, then close it.

✔️ Awesome, I've got everything!

Great. Double check you have saved ExpenseController.java with both the APPROVED status check and the spending cap validation.

ⓧ I'd like to double check the full code

Scroll up and confirm your ExpenseController.java has these chunks present in order inside the createExpense method:

  • The existing budgetRequest.isEmpty() check returning 404.
  • var request = budgetRequest.get() to extract the budget request.
  • The APPROVED status check returning 400 if not approved.
  • The spending cap logic: query existing expenses, sum amounts, compute remaining, reject if over.
  • The save logic: setBudgetRequestId, save, return 200.

Your API now enforces real financial constraints. Clubs cannot overspend, and they cannot bill against unapproved budgets. Next up, you will take on the Secret Mission and build a budget utilization summary for admins.

Secret mission

Budget Utilization Summary

Admins need a bird's-eye view of spending across all clubs. Can you build a summary endpoint that aggregates approved budgets, total expenses, and computes utilization percentages?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with an in-memory database, so there are no ongoing costs.

Resources you used:

  • OpenJDK 17 (installed via apt).
  • Extension Pack for Java (VS Code extension).
  • The club-budget Spring Boot project folder on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or want to extend the API further.

  • Your Spring Boot application continues running on port 8080.
  • The H2 in-memory database resets every time you restart the application, so no data accumulates.
  • OpenJDK 17 and the Extension Pack for Java remain installed on your system.

Pause - I'll come back to this later

Shut down the running application to free up memory. Your project files stay on disk so you can resume later.

  • Switch to the terminal where Spring Boot is running and press Ctrl+C to stop the application.
  • Your project files in the club-budget folder remain intact.
  • To resume later, open the folder in VS Code and run ./mvnw spring-boot:run again.

Delete - I don't want to use this again

Remove all project resources and uninstall the tools you added during this project.

  • Switch to the terminal where Spring Boot is running and press Ctrl+C to stop the application.
  • Remove the project folder by running:
rm -rf ~/Desktop/club-budget
  • Uninstall OpenJDK 17 by running:
sudo apt remove openjdk-17-jdk
  • Uninstall the Extension Pack for Java in VS Code by opening the Extensions sidebar (Ctrl+Shift+X), finding Extension Pack for Java, and clicking Uninstall.

Nice Work!

Nice Work!

Nice work! You just built a fully functional REST API that enforces real-world financial constraints, complete with role-based security and spending-cap validation.

You've learned how to:

  • Build a Spring Boot REST API from scratch with JPA entities, repositories, and controllers that persist data to an in-memory database.
  • Implement role-based access control with Spring Security using HTTP Basic authentication and in-memory users to restrict sensitive endpoints to admins only.
  • Discover and fix a real overspend bug through iterative development. You built billing without validation, observed the failure, then enforced a spending cap that rejects expenses exceeding the approved budget.
  • Secret Mission: Added a budget utilization summary endpoint with computed aggregation data showing each club's approved amount, total spent, and percentage utilized.

Ready to quiz yourself?