AI-Powered Invoice Processing Service

Build a fintech backend that extracts invoice data with OCR and AI.

Introduction

30 Second Summary

Every month, invoices and billing statements pile up. Manually sorting through them to figure out where your money went is tedious, error-prone, and slow. What if a backend service could read those invoices, understand what they contain, and categorize your spending automatically?

In this project, you will build a Smart Expense Analyzer that processes invoice images using OCR and a local AI model, stores structured expense data in a database, and exposes analytics APIs. Along the way, you will implement the enterprise architecture patterns (caching, message queues, circuit breakers, rate limiting, API gateway, JWT authentication) that Solutions Architects use in real-world fintech systems.

What You'll Build

A production-grade Spring Boot backend service that ingests invoice images, extracts text via OCR, categorizes expenses with a local LLM, and serves spending analytics through secured API endpoints.

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

  • An AI-powered invoice processing pipeline that extracts text from images via Tesseract OCR, sends it to a local LLM (Ollama) for categorization, and stores structured expense records in PostgreSQL.
  • A distributed system architecture featuring Redis caching, RabbitMQ async processing, Resilience4j circuit breakers, and a Spring Cloud Gateway with Token Bucket rate limiting.
  • A fintech backend with JWT-secured endpoints, Stripe payment integration (test mode), spending analytics APIs, and an AI chatbot for natural language expense queries.
  • Secret Mission: add a Stripe webhook endpoint that receives payment status updates and automatically marks invoices as paid in the database.

Are there any prerequisites?

This project assumes you are comfortable with Java, Spring Boot, REST APIs, PostgreSQL, and Docker Compose. You should have IntelliJ IDEA and Docker Desktop already installed on your machine (24GB RAM recommended for running the local LLM).

Set Up Project and Docker Infrastructure

Every enterprise backend starts with a solid foundation. Before you write any business logic, you need a project structure that separates concerns and infrastructure services that your application depends on.

In this step, you will create a multi-module Maven project with two modules (a gateway and a core service), spin up Redis and RabbitMQ in Docker containers, and verify that Ollama is ready to serve AI requests.

In this step, get ready to:
  • Create the multi-module Spring Boot 3 project with gateway and core-service modules.
  • Stand up Redis and RabbitMQ via Docker Compose.
  • Verify Ollama and open the project in IntelliJ IDEA.
Create the multi-module Maven project

A multi-module Maven project lets you keep the API gateway and the core service in separate Spring Boot applications. This is important because Spring Cloud Gateway uses the reactive stack, while the core service uses the servlet stack. They cannot share the same Spring Boot application.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project directory structure by running these commands:
mkdir smart-expense-analyzer
cd smart-expense-analyzer
mkdir -p core-service/src/main/java/com/nextwork/expense
mkdir -p core-service/src/main/resources
mkdir -p gateway/src/main/java/com/nextwork/gateway

What does this create?

This creates a parent project folder with two sub-modules inside it. The core-service module holds all the business logic (OCR, AI, payments, analytics). The gateway module handles request routing and global rate limiting.

  • Confirm the directories exist by running:
ls

You should see core-service and gateway listed in the output.

Now you will create the Maven POM files that define your project's dependencies. Start with the root pom.xml that ties the modules together.

  • Create the root pom.xml in the smart-expense-analyzer folder by running:
touch pom.xml
  • Open pom.xml in a text editor and paste the following content:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- Spring Boot 3 parent manages common dependency versions -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.1</version>
        <relativePath/>
    </parent>

    <!-- Root project coordinates -->
    <groupId>com.nextwork</groupId>
    <artifactId>smart-expense-analyzer</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>pom</packaging>

    <!-- Declare both sub-modules -->
    <modules>
        <module>gateway</module>
        <module>core-service</module>
    </modules>

    <properties>
        <java.version>17</java.version>
        <spring-cloud.version>2024.0.0</spring-cloud.version>
        <spring-ai.version>1.0.0</spring-ai.version>
    </properties>

    <!-- BOM imports for Spring Cloud and Spring AI version management -->
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>

What does this POM do?

The root POM acts as the parent for both modules. It inherits from spring-boot-starter-parent so all Spring Boot dependencies get automatic version management.

The dependencyManagement section imports two BOMs (Bill of Materials). The Spring Cloud BOM manages gateway-related versions. The Spring AI BOM manages AI integration versions. Child modules can reference these dependencies without specifying versions.

  • Save pom.xml.

Next, create the core-service module's POM with all the dependencies your expense analyzer needs.

  • Create the core-service POM file by running:
touch core-service/pom.xml
  • Open core-service/pom.xml and paste the following:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- Inherit from the root parent project -->
    <parent>
        <groupId>com.nextwork</groupId>
        <artifactId>smart-expense-analyzer</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>core-service</artifactId>

    <dependencies>
        <!-- Web framework for REST endpoints -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- JPA for database access -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <!-- Redis for caching -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- RabbitMQ for async message processing -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>

        <!-- Spring Security for JWT authentication -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!-- AOP support required by Resilience4j annotations -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <!-- Actuator for health checks and circuit breaker metrics -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <!-- Spring AI integration with local Ollama LLM -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-ollama</artifactId>
        </dependency>

        <!-- Tesseract OCR wrapper for Java -->
        <dependency>
            <groupId>net.sourceforge.tess4j</groupId>
            <artifactId>tess4j</artifactId>
            <version>5.18.0</version>
        </dependency>

        <!-- Stripe payment processing SDK -->
        <dependency>
            <groupId>com.stripe</groupId>
            <artifactId>stripe-java</artifactId>
            <version>32.0.0</version>
        </dependency>

        <!-- Circuit breaker, retry, and rate limiting -->
        <dependency>
            <groupId>io.github.resilience4j</groupId>
            <artifactId>resilience4j-spring-boot3</artifactId>
            <version>2.2.0</version>
        </dependency>

        <!-- PostgreSQL JDBC driver -->
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

What are all these dependencies?

  • spring-boot-starter-web provides REST controller support and an embedded Tomcat server.
  • spring-ai-starter-model-ollama connects to your local Ollama instance for AI-powered expense categorization.
  • tess4j wraps Tesseract OCR so Java can extract text from invoice images.
  • resilience4j-spring-boot3 adds circuit breaker, retry, and rate limiting annotations to protect external service calls.
  • stripe-java handles payment processing in test mode.
  • Save core-service/pom.xml.

Now create the gateway module's POM. This module is lightweight because it only routes requests and applies rate limiting.

  • Create the gateway POM file by running:
touch gateway/pom.xml
  • Open gateway/pom.xml and paste the following:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- Inherit from the root parent project -->
    <parent>
        <groupId>com.nextwork</groupId>
        <artifactId>smart-expense-analyzer</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>gateway</artifactId>

    <dependencies>
        <!-- Spring Cloud Gateway for request routing and filtering -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>

        <!-- Reactive Redis for Token Bucket rate limiting -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Why a separate gateway module?

Spring Cloud Gateway uses Spring WebFlux (the reactive stack). The core service uses Spring MVC (the servlet stack). These two stacks cannot coexist in the same Spring Boot application. Separating them into modules is the standard approach for production microservice architectures.

  • Save gateway/pom.xml.

Finally, create the application configuration file for the core service. This tells Spring Boot where to find PostgreSQL, Redis, RabbitMQ, and Ollama.

  • Create the application config file by running:
touch core-service/src/main/resources/application.yml
  • Open core-service/src/main/resources/application.yml and paste the following:
server:
  port: 8080

spring:
  datasource:
    # PostgreSQL connection - update credentials if yours differ
    url: jdbc:postgresql://localhost:5432/expense_analyzer
    username: postgres
    password: postgres

  jpa:
    hibernate:
      # 'validate' ensures schema matches entities without auto-creating tables
      ddl-auto: validate

  ai:
    ollama:
      # Local Ollama instance URL
      base-url: http://localhost:11434
      chat:
        options:
          # The LLM model for expense categorization
          model: llama3.1:8b

  data:
    redis:
      # Redis container from Docker Compose
      host: localhost
      port: 6379

  rabbitmq:
    # RabbitMQ container from Docker Compose
    host: localhost
    port: 5672
    username: guest
    password: guest

What does this configure?

This file connects all the infrastructure pieces together. The datasource section points to your local PostgreSQL. The spring.ai.ollama section tells Spring AI where to find your local LLM.

Setting ddl-auto: validate means Hibernate will check that your database schema matches your Java entities on startup, but will not create or modify tables automatically. You will define the schema manually in Step 2.

  • Save application.yml.

PostgreSQL credentials not matching?

If your local PostgreSQL uses a different username or password, update the username and password fields in application.yml to match your setup.

help me figure out my PostgreSQL connection settings

✔️ Awesome, I've got everything!

Great. Make sure all four files are saved before moving on.

ⓧ I'd like to double check the full code

Here is each file in full for comparison.

Root pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- Spring Boot 3 parent manages common dependency versions -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.1</version>
        <relativePath/>
    </parent>

    <!-- Root project coordinates -->
    <groupId>com.nextwork</groupId>
    <artifactId>smart-expense-analyzer</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>pom</packaging>

    <!-- Declare both sub-modules -->
    <modules>
        <module>gateway</module>
        <module>core-service</module>
    </modules>

    <properties>
        <java.version>17</java.version>
        <spring-cloud.version>2024.0.0</spring-cloud.version>
        <spring-ai.version>1.0.0</spring-ai.version>
    </properties>

    <!-- BOM imports for Spring Cloud and Spring AI version management -->
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>

core-service/pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- Inherit from the root parent project -->
    <parent>
        <groupId>com.nextwork</groupId>
        <artifactId>smart-expense-analyzer</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>core-service</artifactId>

    <dependencies>
        <!-- Web framework for REST endpoints -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- JPA for database access -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <!-- Redis for caching -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- RabbitMQ for async message processing -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>

        <!-- Spring Security for JWT authentication -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!-- AOP support required by Resilience4j annotations -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <!-- Actuator for health checks and circuit breaker metrics -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <!-- Spring AI integration with local Ollama LLM -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-ollama</artifactId>
        </dependency>

        <!-- Tesseract OCR wrapper for Java -->
        <dependency>
            <groupId>net.sourceforge.tess4j</groupId>
            <artifactId>tess4j</artifactId>
            <version>5.18.0</version>
        </dependency>

        <!-- Stripe payment processing SDK -->
        <dependency>
            <groupId>com.stripe</groupId>
            <artifactId>stripe-java</artifactId>
            <version>32.0.0</version>
        </dependency>

        <!-- Circuit breaker, retry, and rate limiting -->
        <dependency>
            <groupId>io.github.resilience4j</groupId>
            <artifactId>resilience4j-spring-boot3</artifactId>
            <version>2.2.0</version>
        </dependency>

        <!-- PostgreSQL JDBC driver -->
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

gateway/pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- Inherit from the root parent project -->
    <parent>
        <groupId>com.nextwork</groupId>
        <artifactId>smart-expense-analyzer</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>gateway</artifactId>

    <dependencies>
        <!-- Spring Cloud Gateway for request routing and filtering -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>

        <!-- Reactive Redis for Token Bucket rate limiting -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

core-service/src/main/resources/application.yml:

server:
  port: 8080

spring:
  datasource:
    # PostgreSQL connection - update credentials if yours differ
    url: jdbc:postgresql://localhost:5432/expense_analyzer
    username: postgres
    password: postgres

  jpa:
    hibernate:
      # 'validate' ensures schema matches entities without auto-creating tables
      ddl-auto: validate

  ai:
    ollama:
      # Local Ollama instance URL
      base-url: http://localhost:11434
      chat:
        options:
          # The LLM model for expense categorization
          model: llama3.1:8b

  data:
    redis:
      # Redis container from Docker Compose
      host: localhost
      port: 6379

  rabbitmq:
    # RabbitMQ container from Docker Compose
    host: localhost
    port: 5672
    username: guest
    password: guest
Create the Docker Compose file and start services

Your core service depends on Redis for caching and RabbitMQ for async message processing. Docker Compose lets you define both services in a single file and start them with one command.

  • Create the Docker Compose file in the root of your smart-expense-analyzer folder by running:
touch docker-compose.yml
  • Open docker-compose.yml and paste the following:
version: '3'

services:
  # In-memory cache for analytics query results
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  # Message broker for async invoice processing
  rabbitmq:
    image: rabbitmq:3-management
    ports:
      - "5672:5672"    # AMQP protocol port
      - "15672:15672"  # Management UI port
    environment:
      RABBITMQ_DEFAULT_USER: guest
      RABBITMQ_DEFAULT_PASS: guest

What does this set up?

The redis:7-alpine image runs a lightweight Redis instance on port 6379. You will use this for caching expensive analytics queries in Step 6.

The rabbitmq:3-management image includes a web-based management dashboard at http://localhost:15672. Port 5672 is the AMQP protocol port your application connects to for sending and receiving messages.

  • Save docker-compose.yml.
  • Start both containers in detached mode by running:
docker compose up -d

You should see output confirming both containers are created and started.

  • Verify Redis is running by running:
docker exec -it smart-expense-analyzer-redis-1 redis-cli ping

You should see PONG in the output. This confirms Redis is accepting connections.

  • Verify RabbitMQ is running by opening http://localhost:15672 in your browser.
  • Log in with username guest and password guest.

You should see the RabbitMQ management dashboard with a green status indicator.

Container name not matching?

Docker Compose names containers based on your folder name. If the redis-cli ping command fails because it cannot find the container, run docker ps to see the actual container names and substitute accordingly.

help me troubleshoot why my Docker containers are not starting

Verify Ollama and open the project in IntelliJ IDEA

Ollama should already be running from your earlier setup. A quick check confirms the llama3.1:8b model is loaded and responding.

  • Verify Ollama is responding by running:
curl http://localhost:11434/api/tags

You should see a JSON response listing your available models. Confirm that llama3.1:8b appears in the list.

Ollama not responding?

If you see Connection refused, Ollama might not be running. Start it by running ollama serve in a separate terminal window. If the model is missing, pull it by running ollama pull llama3.1:8b (this is a ~4.7GB download).

help me get Ollama running with the llama3.1:8b model

Now open the project in IntelliJ IDEA so Maven can resolve your dependencies.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type IntelliJ and press Enter to open it.
  • Click Open on the welcome screen.
  • Navigate to Desktop and select the smart-expense-analyzer folder.
  • Click Open.

IntelliJ will detect the pom.xml and start downloading dependencies. This may take a minute or two on the first run.

How do I know Maven finished?

Look for the green checkmark in the bottom status bar. If IntelliJ prompts you to import the project as a Maven project, click Trust Project and then Import. Once the progress bar disappears, your dependencies are resolved.

Your project skeleton is in place and all infrastructure services are running. Next up, you will design the database schema and add JWT authentication to secure your API endpoints.

Design the Database Schema and Configure JWT Security

Your infrastructure is running and the project skeleton compiles. Now you need a place to store data and a way to keep the API secure.

In this step you will create the database tables that hold users, invoices, expenses, and payments. You will also set up JWT-based authentication so that only registered users can access protected endpoints.

In this step, get ready to:
  • Design and run a Flyway migration that creates four tables with a shard-key-ready partition strategy.
  • Configure Spring Security with JWT token generation, validation, and a stateless filter chain.
  • Build registration and login endpoints with BCrypt password hashing.
Create the database schema with Flyway

Every table in this project includes a user_id column. This is intentional. In a production system you would shard the database by user, routing each user's data to a specific partition. Designing the schema this way from the start means you never have to retrofit it later.

What is a shard-key-ready schema?

Sharding splits a database across multiple nodes so no single machine holds all the data. The shard key (here, user_id) determines which node owns a given row.

By including user_id in every table now, all queries can filter by that column. When you scale to tools like Citus or application-level routing, the migration is trivial because the data model already supports it.

Flyway manages database migrations as versioned SQL files. Spring Boot auto-detects Flyway on the classpath and runs any pending migrations at startup.

  • Add the Flyway dependencies to your core-service/pom.xml inside the <dependencies> section:
<!-- Database migration -->
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
</dependency>

Why two Flyway dependencies?

Starting with Flyway 10 (which Spring Boot 3.3+ uses), database-specific support is split into separate modules. flyway-core provides the migration engine. flyway-database-postgresql adds the PostgreSQL dialect. You need both.

  • Create the migration directory structure inside core-service by running:
mkdir -p core-service/src/main/resources/db/migration
  • Create a new file called V1__init_schema.sql inside core-service/src/main/resources/db/migration/ and paste the following SQL:
-- Users table: stores registered accounts
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Invoices table: tracks uploaded invoice images and OCR status
CREATE TABLE invoices (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,
    raw_ocr_text TEXT,
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    created_at TIMESTAMP DEFAULT NOW()
);

-- Expenses table: structured data extracted from invoices by the LLM
CREATE TABLE expenses (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    invoice_id UUID NOT NULL,
    user_id UUID NOT NULL,
    vendor VARCHAR(255),
    amount DECIMAL(10,2),
    category VARCHAR(100),
    expense_date DATE,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Payments table: records Stripe payment intents for invoices
CREATE TABLE payments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    invoice_id UUID NOT NULL,
    user_id UUID NOT NULL,
    stripe_payment_intent_id VARCHAR(255),
    amount DECIMAL(10,2),
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    created_at TIMESTAMP DEFAULT NOW()
);

What does this schema do?

  • users holds credentials for registration and login.
  • invoices stores uploaded images and tracks processing status through PENDING, PROCESSING, COMPLETED, and FAILED states.
  • expenses holds the structured data the LLM extracts from each invoice (vendor, amount, category, date).
  • payments records Stripe payment intents and their current status.
  • Every table includes user_id so queries always filter by the shard key, making this database horizontally scalable.
  • Open core-service/src/main/resources/application.yml and add the following lines at the bottom of the file:
spring:
  flyway:
    enabled: true

What does this config do?

This explicitly enables Flyway. When the application starts, Flyway checks the db/migration folder for SQL files and applies any that have not yet run against the database.

  • Also update the spring.jpa.hibernate.ddl-auto value in the same file. Find this line:
    hibernate:
      ddl-auto: validate
  • Keep it as validate. This ensures Hibernate checks that your JPA entities match the tables Flyway creates, but never modifies the schema itself.
  • Save core-service/pom.xml, application.yml, and V1__init_schema.sql.
  • Make sure your local PostgreSQL is running, then start the core-service application from IntelliJ. In the console output you should see a line like Successfully applied 1 migration to schema "public".

Application fails to start with a Flyway error?

  • Check that both flyway-core and flyway-database-postgresql are in your pom. Missing the PostgreSQL module causes a "No database found" error.
  • Verify your spring.datasource.url in application.yml points to a running PostgreSQL instance and the database exists.
  • Make sure the migration file name starts with a capital V followed by a version number and two underscores (e.g. V1__init_schema.sql).

Help me debug my Flyway migration error

Configure Spring Security with JWT authentication

Your API needs to know who is making each request. JWT (JSON Web Token) solves this by issuing a signed token at login that the client sends back on every subsequent request.

You will create three classes: a utility that generates and validates tokens, a filter that intercepts incoming requests, and a configuration that ties them into Spring Security.

  • Add the JJWT library dependencies to core-service/pom.xml inside the <dependencies> section:
<!-- JWT token handling -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>

Why three JJWT artifacts?

JJWT separates its API from its implementation. jjwt-api provides the interfaces your code compiles against. jjwt-impl and jjwt-jackson are runtime-only dependencies that handle the actual signing and JSON parsing.

  • Save core-service/pom.xml and let IntelliJ reload the Maven project.
  • Create the package com.nextwork.expense.security under core-service/src/main/java/com/nextwork/expense/.
  • Inside the security package, create a new file called JwtUtil.java and paste the following:
package com.nextwork.expense.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;

@Component
public class JwtUtil {

    // Secret key loaded from application.yml
    @Value("${jwt.secret}")
    private String SECRET_KEY;

    // Token expires after 24 hours
    private static final long EXPIRATION_MS = 86400000;

    // Build a signing key from the secret string
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8));
    }

    // Create a signed JWT for the given email
    public String generateToken(String email) {
        return Jwts.builder()
                .subject(email)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + EXPIRATION_MS))
                .signWith(getSigningKey())
                .compact();
    }

    // Extract the email (subject) from a token
    public String extractEmail(String token) {
        return getClaims(token).getSubject();
    }

    // Return true if the token is valid and not expired
    public boolean validateToken(String token) {
        try {
            getClaims(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    // Parse and return all claims from the token
    private Claims getClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
}

What does JwtUtil do?

  • generateToken() creates a token signed with HMAC-SHA256. The token embeds the user's email as the subject and expires after 24 hours.
  • validateToken() verifies the signature and expiry. If either check fails, it returns false.
  • extractEmail() reads the subject claim so the filter knows which user made the request.
  • In the same security package, create JwtAuthenticationFilter.java with this code:
package com.nextwork.expense.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.ArrayList;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    public JwtAuthenticationFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        // Read the Authorization header
        String authHeader = request.getHeader("Authorization");

        // Skip if no Bearer token is present
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // Extract and validate the token
        String token = authHeader.substring(7);

        if (jwtUtil.validateToken(token)) {
            String email = jwtUtil.extractEmail(token);

            // Set the authenticated user in the security context
            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(email, null, new ArrayList<>());
            authentication.setDetails(
                    new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }
}

What does the filter do?

This filter runs once per request. It looks for a Bearer token in the Authorization header. If the token is valid, it tells Spring Security who the user is. If no token is present, the request continues unauthenticated (Spring Security will reject it later if the route requires auth).

  • Create SecurityConfig.java in the same security package:
package com.nextwork.expense.security;

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.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // Disable CSRF since we use stateless JWT tokens
            .csrf(csrf -> csrf.disable())
            // No server-side sessions needed
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // Define public and protected routes
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            // Insert our JWT filter before Spring's default auth filter
            .addFilterBefore(jwtAuthenticationFilter,
                UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

What does SecurityConfig do?

  • Disables CSRF protection. CSRF tokens are designed for browser-based session cookies. A stateless JWT API does not need them.
  • Sets session management to STATELESS so Spring never creates an HTTP session.
  • Permits unauthenticated access to /api/auth/** (registration and login). All other routes require a valid JWT.
  • Registers the JwtAuthenticationFilter to run before Spring's default authentication filter.
  • Add the JWT secret to application.yml. Open the file and add this at the root level (not nested under spring:):
jwt:
  secret: a-secure-secret-key-for-development

This secret is for local development only

In a production deployment you would load this from an environment variable or a secrets manager. For local development, a hardcoded string is fine.

  • Save all four files (JwtUtil.java, JwtAuthenticationFilter.java, SecurityConfig.java, application.yml).
  • Verify the project still compiles by clicking Build then Build Project in IntelliJ (or press Cmd+F9 on macOS / Ctrl+F9 on Windows). You should see "Build completed successfully" in the status bar.

Seeing import errors in JwtUtil?

  • Make sure IntelliJ reloaded Maven after you added the JJWT dependencies. Right-click the pom.xml and select Maven then Reload project.
  • Confirm all three JJWT artifacts (jjwt-api, jjwt-impl, jjwt-jackson) use the same version number 0.12.6.

Help me fix JJWT import errors in my Spring Boot project

Build the registration and login endpoints

The security layer is in place but there is no way for users to get a token yet. You need a User entity mapped to the users table, a repository to query it, and two endpoints: one for registration and one for login.

  • Create the package com.nextwork.expense.entity under core-service/src/main/java/com/nextwork/expense/.
  • Inside it, create User.java with the following code:
package com.nextwork.expense.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    // Email is unique so no two users can register with the same address
    @Column(unique = true, nullable = false)
    private String email;

    // Stores the BCrypt hash, never the plaintext password
    @Column(name = "password_hash", nullable = false)
    private String passwordHash;

    @Column(name = "created_at")
    private LocalDateTime createdAt;

    // Auto-set timestamp before first save
    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
    }

    // Getters and setters
    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    public String getPasswordHash() { return passwordHash; }
    public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

What does the User entity do?

  • Maps directly to the users table you created with Flyway.
  • Uses GenerationType.UUID so the JPA provider generates UUID primary keys automatically.
  • The @PrePersist hook sets the creation timestamp before the entity is written to the database.
  • Create the package com.nextwork.expense.repository and add UserRepository.java:
package com.nextwork.expense.repository;

import com.nextwork.expense.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;
import java.util.UUID;

// Spring Data auto-implements CRUD methods at runtime
public interface UserRepository extends JpaRepository<User, UUID> {
    // Custom query method: Spring generates SQL from the method name
    Optional<User> findByEmail(String email);
}

What does UserRepository do?

By extending JpaRepository, Spring Data gives you methods like save(), findById(), and delete() for free. The custom findByEmail() method is implemented automatically based on its name.

  • Create the package com.nextwork.expense.controller and add AuthController.java:
package com.nextwork.expense.controller;

import com.nextwork.expense.entity.User;
import com.nextwork.expense.repository.UserRepository;
import com.nextwork.expense.security.JwtUtil;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder passwordEncoder;
    private final JwtUtil jwtUtil;

    public AuthController(UserRepository userRepository,
                          BCryptPasswordEncoder passwordEncoder,
                          JwtUtil jwtUtil) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.jwtUtil = jwtUtil;
    }

    // Register a new user with email and password
    @PostMapping("/register")
    public ResponseEntity<?> register(@RequestBody Map<String, String> request) {
        String email = request.get("email");
        String password = request.get("password");

        // Reject if email is already taken
        if (userRepository.findByEmail(email).isPresent()) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "Email already registered"));
        }

        // Hash the password and save the user
        User user = new User();
        user.setEmail(email);
        user.setPasswordHash(passwordEncoder.encode(password));
        userRepository.save(user);

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(Map.of("message", "User registered successfully"));
    }

    // Authenticate and return a JWT token
    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody Map<String, String> request) {
        String email = request.get("email");
        String password = request.get("password");

        return userRepository.findByEmail(email)
                .filter(user -> passwordEncoder.matches(password, user.getPasswordHash()))
                .map(user -> {
                    String token = jwtUtil.generateToken(email);
                    return ResponseEntity.ok(Map.of("token", token));
                })
                .orElse(ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                        .body(Map.of("error", "Invalid credentials")));
    }
}

What does AuthController do?

  • POST /api/auth/register checks for a duplicate email, hashes the password with BCrypt, saves the user, and returns 201 Created.
  • POST /api/auth/login looks up the user by email, verifies the password hash, and returns a signed JWT. If credentials are wrong, it returns 401 Unauthorized.
  • Save all files.
  • Restart the core-service application from IntelliJ.
  • Test registration by running this in your terminal:
curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "password": "secret123"}'

You should see a JSON response: {"message":"User registered successfully"}.

  • Test login by running:
curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "password": "secret123"}'

You should see a JSON response containing a token field with a long encoded string. That is your signed JWT.

Getting a 403 Forbidden on register?

  • Verify that SecurityConfig permits /api/auth/**. The double wildcard is required to match nested paths.
  • Check that CSRF is disabled. Without this, Spring rejects POST requests that lack a CSRF token.
  • Make sure the @RequestMapping("/api/auth") on the controller matches what you permitted in the filter chain.

Help me debug my Spring Security 403 error

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is the complete V1__init_schema.sql:

-- Users table: stores registered accounts
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Invoices table: tracks uploaded invoice images and OCR status
CREATE TABLE invoices (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,
    raw_ocr_text TEXT,
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    created_at TIMESTAMP DEFAULT NOW()
);

-- Expenses table: structured data extracted from invoices by the LLM
CREATE TABLE expenses (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    invoice_id UUID NOT NULL,
    user_id UUID NOT NULL,
    vendor VARCHAR(255),
    amount DECIMAL(10,2),
    category VARCHAR(100),
    expense_date DATE,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Payments table: records Stripe payment intents for invoices
CREATE TABLE payments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    invoice_id UUID NOT NULL,
    user_id UUID NOT NULL,
    stripe_payment_intent_id VARCHAR(255),
    amount DECIMAL(10,2),
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    created_at TIMESTAMP DEFAULT NOW()
);

Here is the complete JwtUtil.java:

package com.nextwork.expense.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;

@Component
public class JwtUtil {

    // Secret key loaded from application.yml
    @Value("${jwt.secret}")
    private String SECRET_KEY;

    // Token expires after 24 hours
    private static final long EXPIRATION_MS = 86400000;

    // Build a signing key from the secret string
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8));
    }

    // Create a signed JWT for the given email
    public String generateToken(String email) {
        return Jwts.builder()
                .subject(email)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + EXPIRATION_MS))
                .signWith(getSigningKey())
                .compact();
    }

    // Extract the email (subject) from a token
    public String extractEmail(String token) {
        return getClaims(token).getSubject();
    }

    // Return true if the token is valid and not expired
    public boolean validateToken(String token) {
        try {
            getClaims(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    // Parse and return all claims from the token
    private Claims getClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
}

Here is the complete JwtAuthenticationFilter.java:

package com.nextwork.expense.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.ArrayList;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    public JwtAuthenticationFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        // Read the Authorization header
        String authHeader = request.getHeader("Authorization");

        // Skip if no Bearer token is present
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // Extract and validate the token
        String token = authHeader.substring(7);

        if (jwtUtil.validateToken(token)) {
            String email = jwtUtil.extractEmail(token);

            // Set the authenticated user in the security context
            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(email, null, new ArrayList<>());
            authentication.setDetails(
                    new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }
}

Here is the complete SecurityConfig.java:

package com.nextwork.expense.security;

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.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // Disable CSRF since we use stateless JWT tokens
            .csrf(csrf -> csrf.disable())
            // No server-side sessions needed
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // Define public and protected routes
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            // Insert our JWT filter before Spring's default auth filter
            .addFilterBefore(jwtAuthenticationFilter,
                UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Here is the complete User.java:

package com.nextwork.expense.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    // Email is unique so no two users can register with the same address
    @Column(unique = true, nullable = false)
    private String email;

    // Stores the BCrypt hash, never the plaintext password
    @Column(name = "password_hash", nullable = false)
    private String passwordHash;

    @Column(name = "created_at")
    private LocalDateTime createdAt;

    // Auto-set timestamp before first save
    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
    }

    // Getters and setters
    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    public String getPasswordHash() { return passwordHash; }
    public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

Here is the complete UserRepository.java:

package com.nextwork.expense.repository;

import com.nextwork.expense.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;
import java.util.UUID;

// Spring Data auto-implements CRUD methods at runtime
public interface UserRepository extends JpaRepository<User, UUID> {
    // Custom query method: Spring generates SQL from the method name
    Optional<User> findByEmail(String email);
}

Here is the complete AuthController.java:

package com.nextwork.expense.controller;

import com.nextwork.expense.entity.User;
import com.nextwork.expense.repository.UserRepository;
import com.nextwork.expense.security.JwtUtil;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder passwordEncoder;
    private final JwtUtil jwtUtil;

    public AuthController(UserRepository userRepository,
                          BCryptPasswordEncoder passwordEncoder,
                          JwtUtil jwtUtil) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.jwtUtil = jwtUtil;
    }

    // Register a new user with email and password
    @PostMapping("/register")
    public ResponseEntity<?> register(@RequestBody Map<String, String> request) {
        String email = request.get("email");
        String password = request.get("password");

        // Reject if email is already taken
        if (userRepository.findByEmail(email).isPresent()) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "Email already registered"));
        }

        // Hash the password and save the user
        User user = new User();
        user.setEmail(email);
        user.setPasswordHash(passwordEncoder.encode(password));
        userRepository.save(user);

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(Map.of("message", "User registered successfully"));
    }

    // Authenticate and return a JWT token
    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody Map<String, String> request) {
        String email = request.get("email");
        String password = request.get("password");

        return userRepository.findByEmail(email)
                .filter(user -> passwordEncoder.matches(password, user.getPasswordHash()))
                .map(user -> {
                    String token = jwtUtil.generateToken(email);
                    return ResponseEntity.ok(Map.of("token", token));
                })
                .orElse(ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                        .body(Map.of("error", "Invalid credentials")));
    }
}

Here is the updated application.yml (showing only the lines you added this step):

spring:
  flyway:
    enabled: true

jwt:
  secret: a-secure-secret-key-for-development

Your database is structured, your API is locked down, and you have working auth endpoints. Next up, you will build the OCR pipeline that extracts text from uploaded invoice images using Tesseract.

Build the OCR Pipeline with Tesseract

Your database schema and JWT security are in place. Every endpoint beyond /api/auth/** now requires a valid Bearer token, which means your API is locked down and ready for real features.

The first real feature is the OCR pipeline. When a user uploads an invoice image, the system needs to extract the text from that image so the AI can later analyze it. You will use Tesseract (an open-source OCR engine) through its Java wrapper, Tess4J, to convert images into raw text strings.

In this step, get ready to:
  • Add the Tess4J Maven dependency to the project.
  • Build an OCR service that extracts text from uploaded images.
  • Create a secured REST endpoint that accepts invoice images and stores the extracted text.
Add the Tess4J dependency

Tesseract is already installed on your machine from Step 1. Tess4J is the Java library that lets your Spring Boot application call Tesseract's OCR engine programmatically.

  • Confirm Tesseract is available by running this command:
tesseract --version

✔️ I see a version number

Tesseract is ready. Move on to adding the Maven dependency below.

ⓧ Command not found

  • Install Tesseract via Homebrew by running:
brew install tesseract
  • Once installed, re-run the version check to confirm it works.
  • Open core-service/pom.xml in IntelliJ.
  • Inside the <dependencies> section, add the Tess4J dependency:
<!-- Tesseract OCR Java wrapper -->
<dependency>
    <groupId>net.sourceforge.tess4j</groupId>
    <artifactId>tess4j</artifactId>
    <version>5.18.0</version>
</dependency>

What does this dependency do?

Tess4J is a Java JNA wrapper for Tesseract OCR. It provides a simple API to pass image files to Tesseract and get extracted text back, without needing to shell out to the command line.

  • Save pom.xml.
  • When IntelliJ shows a notification to reload Maven changes, click the reload icon (or press Cmd+Shift+I on macOS).
  • In the Maven tool window on the right, expand Dependencies. You should see net.sourceforge.tess4j:tess4j:5.18.0 listed.

Dependency not downloading?

Make sure you placed the dependency inside the existing <dependencies> block, not outside it. Also check you have internet connectivity for Maven Central.

Help me troubleshoot why my Tess4J Maven dependency is not resolving in IntelliJ.

Build the InvoiceOcrService

The OCR service handles the heavy lifting: accepting an uploaded file, passing it to Tesseract, and returning the extracted text. You will isolate this logic in its own service class so the controller stays clean.

  • Create a new package com.nextwork.expense.service inside core-service/src/main/java/com/nextwork/expense/.
  • Create a new file InvoiceOcrService.java in that package with the following code:
package com.nextwork.expense.service;

import net.sourceforge.tess4j.Tesseract;
import net.sourceforge.tess4j.TesseractException;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;

@Service
public class InvoiceOcrService {

    // Accepts an uploaded image and returns the extracted text
    public String processImage(MultipartFile file) {
        File tempFile = null;
        try {
            // Save the uploaded file to a temporary location on disk
            tempFile = File.createTempFile("ocr_", "_" + file.getOriginalFilename());
            file.transferTo(tempFile);

            // Initialize the Tesseract OCR engine
            Tesseract tesseract = new Tesseract();
            tesseract.setDatapath("/opt/homebrew/share/tessdata");
            tesseract.setLanguage("eng");

            // Run OCR on the temporary image file
            String ocrText = tesseract.doOCR(tempFile);
            return ocrText;

        } catch (IOException e) {
            throw new RuntimeException("Failed to process image file", e);
        } catch (TesseractException e) {
            throw new RuntimeException("OCR processing failed", e);
        } finally {
            // Clean up the temporary file
            if (tempFile != null && tempFile.exists()) {
                tempFile.delete();
            }
        }
    }
}

What does this code do?

  • file.transferTo(tempFile) writes the uploaded bytes to a temporary file on disk, because Tesseract needs a file path to read from.
  • setDatapath("/opt/homebrew/share/tessdata") tells Tesseract where to find its trained language data files. This is the default Homebrew location on Apple Silicon Macs.
  • doOCR(tempFile) runs the actual optical character recognition and returns the extracted text as a String.
  • The finally block deletes the temp file regardless of success or failure, preventing disk space leaks.

Intel Mac users

If you are on an Intel Mac, your tessdata path is /usr/local/share/tessdata instead. Update the setDatapath line accordingly.

  • Save InvoiceOcrService.java.
  • Confirm IntelliJ shows no red underlines on the imports. The net.sourceforge.tess4j imports should resolve from the dependency you added.

Red underlines on Tess4J imports?

This usually means Maven has not finished downloading the dependency. Click the Maven reload button in IntelliJ and wait for the download to complete. Check the Build output window at the bottom for progress.

Help me fix unresolved Tess4J imports in my Spring Boot project.

Create the Invoice entity and repository

Before the controller can store OCR results, you need a JPA entity that maps to the invoices table you created in Step 2. The repository provides the data access methods.

  • Create a new file Invoice.java in the com.nextwork.expense.entity package with the following code:
package com.nextwork.expense.entity;

import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;

import java.time.LocalDateTime;
import java.util.UUID;

@Entity
@Table(name = "invoices")
public class Invoice {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    // Links invoice to the user who uploaded it
    @Column(name = "user_id")
    private UUID userId;

    // Stores the full text extracted by OCR
    @Column(name = "raw_ocr_text", columnDefinition = "TEXT")
    private String rawOcrText;

    // Tracks processing state: PENDING, PROCESSING, COMPLETED, FAILED
    private String status;

    @CreationTimestamp
    @Column(name = "created_at")
    private LocalDateTime createdAt;

    // Getters and setters
    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }

    public UUID getUserId() { return userId; }
    public void setUserId(UUID userId) { this.userId = userId; }

    public String getRawOcrText() { return rawOcrText; }
    public void setRawOcrText(String rawOcrText) { this.rawOcrText = rawOcrText; }

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

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

What does this code do?

  • @Table(name = "invoices") maps this entity to the invoices table you created with Flyway in Step 2.
  • userId is the partition key. Every query filters by user, which makes this schema sharding-ready if you ever scale to multiple database nodes.
  • @CreationTimestamp automatically sets the timestamp when Hibernate inserts a new row.
  • Create a new file InvoiceRepository.java in the com.nextwork.expense.repository package with the following code:
package com.nextwork.expense.repository;

import com.nextwork.expense.entity.Invoice;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.UUID;

// Spring Data auto-implements CRUD + custom query methods
public interface InvoiceRepository extends JpaRepository<Invoice, UUID> {
    List<Invoice> findByUserId(UUID userId);
}
  • Save both files.
  • Confirm IntelliJ shows no compilation errors in either file.

Now you have the data layer ready. The controller can save OCR results to the database.

  • Create a new file InvoiceController.java in the com.nextwork.expense.controller package with the following code:
package com.nextwork.expense.controller;

import com.nextwork.expense.entity.Invoice;
import com.nextwork.expense.entity.User;
import com.nextwork.expense.repository.InvoiceRepository;
import com.nextwork.expense.repository.UserRepository;
import com.nextwork.expense.service.InvoiceOcrService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.util.Map;
import java.util.UUID;

@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {

    private final InvoiceOcrService invoiceOcrService;
    private final InvoiceRepository invoiceRepository;
    private final UserRepository userRepository;

    public InvoiceController(InvoiceOcrService invoiceOcrService,
                             InvoiceRepository invoiceRepository,
                             UserRepository userRepository) {
        this.invoiceOcrService = invoiceOcrService;
        this.invoiceRepository = invoiceRepository;
        this.userRepository = userRepository;
    }

    @PostMapping("/upload")
    public ResponseEntity<?> uploadInvoice(@RequestParam("image") MultipartFile image) {
        // Extract the authenticated user's email from the security context
        String email = SecurityContextHolder.getContext()
                .getAuthentication().getName();

        // Look up the user record in the database
        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new RuntimeException("User not found"));

        // Run OCR on the uploaded image
        String ocrText = invoiceOcrService.processImage(image);

        // Create and save the invoice record
        Invoice invoice = new Invoice();
        invoice.setUserId(user.getId());
        invoice.setRawOcrText(ocrText);
        invoice.setStatus("PENDING");
        invoiceRepository.save(invoice);

        // Return the invoice ID and extracted text
        return ResponseEntity.ok(Map.of(
                "id", invoice.getId(),
                "ocrText", ocrText
        ));
    }
}

What does this code do?

  • SecurityContextHolder.getContext().getAuthentication().getName() retrieves the email of the currently authenticated user from the JWT token that your filter validated.
  • The endpoint accepts a MultipartFile named image. Clients send a multipart/form-data request with the file attached.
  • After OCR completes, the raw text and a PENDING status are saved. Later steps will update this status as AI analysis runs.
  • Save InvoiceController.java.
  • Run the application from IntelliJ (click the green play button on your main application class).
  • You should see Started ExpenseApplication in the console with no errors.

Application fails to start?

If you see a bean creation error for InvoiceOcrService, check that the class has the @Service annotation and is in a package that Spring Boot scans (under com.nextwork.expense).

If you see errors about the invoices table, make sure your PostgreSQL container is running and Flyway ran the migration successfully.

Help me debug why my Spring Boot app fails to start after adding the InvoiceController and InvoiceOcrService.

Now test the full pipeline end-to-end. You will register a user, get a JWT token, and upload an image to the OCR endpoint.

  • Register a test user by running this command in a new terminal:
curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "password": "password123"}'
  • Log in to get a JWT token by running:
curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "password": "password123"}'
  • Copy the token value from the response.
  • Find any image file with text on it (a screenshot of a receipt, a photo of a bill, or even a screenshot of text from your browser).
  • Upload the image to the OCR endpoint by running:
curl -X POST http://localhost:8080/api/invoices/upload \
  -H "Authorization: Bearer [[JWT_TOKEN="your JWT token from the login response"]]" \
  -F "image=@[[IMAGE_PATH="path/to/your/image.png"]]"
  • You should see a JSON response with an id (UUID) and ocrText (the text extracted from your image).

What should I see?

The ocrText field contains whatever text Tesseract could read from the image. It does not need to be perfect. OCR accuracy depends on image quality, font clarity, and resolution. Even partial extraction proves the pipeline works.

Getting a 403 Forbidden?

This means your JWT token is missing or invalid. Make sure you copied the full token string from the login response and included it in the Authorization: Bearer header with no extra spaces.

If you see an OCR error about missing tessdata, check that the path /opt/homebrew/share/tessdata exists and contains eng.traineddata.

Help me fix my OCR upload endpoint that is returning errors.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for each file created in this step.

package com.nextwork.expense.service;

import net.sourceforge.tess4j.Tesseract;
import net.sourceforge.tess4j.TesseractException;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;

@Service
public class InvoiceOcrService {

    // Accepts an uploaded image and returns the extracted text
    public String processImage(MultipartFile file) {
        File tempFile = null;
        try {
            // Save the uploaded file to a temporary location on disk
            tempFile = File.createTempFile("ocr_", "_" + file.getOriginalFilename());
            file.transferTo(tempFile);

            // Initialize the Tesseract OCR engine
            Tesseract tesseract = new Tesseract();
            tesseract.setDatapath("/opt/homebrew/share/tessdata");
            tesseract.setLanguage("eng");

            // Run OCR on the temporary image file
            String ocrText = tesseract.doOCR(tempFile);
            return ocrText;

        } catch (IOException e) {
            throw new RuntimeException("Failed to process image file", e);
        } catch (TesseractException e) {
            throw new RuntimeException("OCR processing failed", e);
        } finally {
            // Clean up the temporary file
            if (tempFile != null && tempFile.exists()) {
                tempFile.delete();
            }
        }
    }
}
package com.nextwork.expense.entity;

import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;

import java.time.LocalDateTime;
import java.util.UUID;

@Entity
@Table(name = "invoices")
public class Invoice {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    // Links invoice to the user who uploaded it
    @Column(name = "user_id")
    private UUID userId;

    // Stores the full text extracted by OCR
    @Column(name = "raw_ocr_text", columnDefinition = "TEXT")
    private String rawOcrText;

    // Tracks processing state: PENDING, PROCESSING, COMPLETED, FAILED
    private String status;

    @CreationTimestamp
    @Column(name = "created_at")
    private LocalDateTime createdAt;

    // Getters and setters
    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }

    public UUID getUserId() { return userId; }
    public void setUserId(UUID userId) { this.userId = userId; }

    public String getRawOcrText() { return rawOcrText; }
    public void setRawOcrText(String rawOcrText) { this.rawOcrText = rawOcrText; }

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

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
package com.nextwork.expense.repository;

import com.nextwork.expense.entity.Invoice;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.UUID;

// Spring Data auto-implements CRUD + custom query methods
public interface InvoiceRepository extends JpaRepository<Invoice, UUID> {
    List<Invoice> findByUserId(UUID userId);
}
package com.nextwork.expense.controller;

import com.nextwork.expense.entity.Invoice;
import com.nextwork.expense.entity.User;
import com.nextwork.expense.repository.InvoiceRepository;
import com.nextwork.expense.repository.UserRepository;
import com.nextwork.expense.service.InvoiceOcrService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.util.Map;
import java.util.UUID;

@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {

    private final InvoiceOcrService invoiceOcrService;
    private final InvoiceRepository invoiceRepository;
    private final UserRepository userRepository;

    public InvoiceController(InvoiceOcrService invoiceOcrService,
                             InvoiceRepository invoiceRepository,
                             UserRepository userRepository) {
        this.invoiceOcrService = invoiceOcrService;
        this.invoiceRepository = invoiceRepository;
        this.userRepository = userRepository;
    }

    @PostMapping("/upload")
    public ResponseEntity<?> uploadInvoice(@RequestParam("image") MultipartFile image) {
        // Extract the authenticated user's email from the security context
        String email = SecurityContextHolder.getContext()
                .getAuthentication().getName();

        // Look up the user record in the database
        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new RuntimeException("User not found"));

        // Run OCR on the uploaded image
        String ocrText = invoiceOcrService.processImage(image);

        // Create and save the invoice record
        Invoice invoice = new Invoice();
        invoice.setUserId(user.getId());
        invoice.setRawOcrText(ocrText);
        invoice.setStatus("PENDING");
        invoiceRepository.save(invoice);

        // Return the invoice ID and extracted text
        return ResponseEntity.ok(Map.of(
                "id", invoice.getId(),
                "ocrText", ocrText
        ));
    }
}

Your OCR pipeline is working. You can upload any image and get text back from it. Next up, you will feed that extracted text to a local AI model that intelligently categorizes expenses and pulls out structured data like vendor names, amounts, and dates.

Integrate AI Analysis with Ollama and Spring AI

Your OCR pipeline extracts raw text from invoice images. Raw text alone is not useful for analytics or payments.

In this step, you will connect the OCR output to your local Ollama LLM via Spring AI. The LLM reads the messy OCR text and extracts structured data: vendor name, amount, date, and category.

In this step, get ready to:
  • Configure Spring AI to connect to your local Ollama instance.
  • Build an AI analysis service that extracts structured expense data from OCR text.
  • Wire the AI analysis into the invoice upload flow so expenses are saved automatically.
Configure Spring AI to connect to Ollama

Spring AI needs to know where your Ollama instance is running and which model to use. You set this in your application configuration file.

  • Open core-service/src/main/resources/application.yml in IntelliJ.
  • Add the following Spring AI configuration block to your YAML file:
spring:
  ai:
    ollama:
      base-url: http://localhost:11434
      chat:
        options:
          model: llama3.1:8b

What does this configuration do?

  • spring.ai.ollama.base-url tells Spring AI where to find Ollama on your machine. The default port is 11434.
  • spring.ai.ollama.chat.options.model selects the llama3.1:8b model you pulled earlier. Spring AI uses this model for all chat requests unless overridden at runtime.
  • Save application.yml.

Application failing to start?

  • Make sure your YAML indentation is correct. YAML uses spaces, not tabs.
  • Confirm Ollama is running by opening http://localhost:11434 in your browser. You should see "Ollama is running".
  • If Ollama is not running, start it with ollama serve in a separate terminal.

Why can't Spring AI connect to my Ollama model?

Build the expense analysis service

The analysis service is responsible for sending OCR text to the LLM with a structured prompt, then parsing the JSON response into a typed Java object. You need three new files: a DTO to hold the result, the Expense entity, and the service itself.

  • Create a new file ExpenseAnalysisResult.java inside the core-service/src/main/java/com/nextwork/expense/dto/ package.
  • Add the following code to define the DTO as a Java record:
package com.nextwork.expense.dto;

import java.math.BigDecimal;

// Holds the structured result returned by the LLM after analyzing OCR text
public record ExpenseAnalysisResult(
    String vendor,
    BigDecimal amount,
    String category,
    String expenseDate
) {}

What is a Java record?

A Java record is a compact class that automatically generates a constructor, getters, equals(), hashCode(), and toString(). Records are ideal for immutable data transfer objects like this analysis result.

  • Save ExpenseAnalysisResult.java.
  • Create a new file Expense.java inside the core-service/src/main/java/com/nextwork/expense/entity/ package.
  • Add the following JPA entity code:
package com.nextwork.expense.entity;

import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;

// Represents a single parsed expense extracted from an invoice
@Entity
@Table(name = "expenses")
public class Expense {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    // Links back to the source invoice
    @Column(name = "invoice_id")
    private UUID invoiceId;

    // Partition key for sharding-ready queries
    @Column(name = "user_id")
    private UUID userId;

    private String vendor;

    // Precision and scale match the database column definition
    @Column(precision = 10, scale = 2)
    private BigDecimal amount;

    private String category;

    @Column(name = "expense_date")
    private LocalDate expenseDate;

    @CreationTimestamp
    @Column(name = "created_at")
    private LocalDateTime createdAt;

    // Getters and setters
    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }

    public UUID getInvoiceId() { return invoiceId; }
    public void setInvoiceId(UUID invoiceId) { this.invoiceId = invoiceId; }

    public UUID getUserId() { return userId; }
    public void setUserId(UUID userId) { this.userId = userId; }

    public String getVendor() { return vendor; }
    public void setVendor(String vendor) { this.vendor = vendor; }

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

    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }

    public LocalDate getExpenseDate() { return expenseDate; }
    public void setExpenseDate(LocalDate expenseDate) { this.expenseDate = expenseDate; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

What does this entity do?

  • Each Expense row maps to one parsed line item from an invoice.
  • The invoiceId links back to the source invoice for traceability.
  • The userId acts as the partition key. Every query in the repository filters by userId, making the schema sharding-ready.
  • Save Expense.java.
  • Create a new file ExpenseRepository.java inside the core-service/src/main/java/com/nextwork/expense/repository/ package.
  • Add the following repository interface:
package com.nextwork.expense.repository;

import com.nextwork.expense.entity.Expense;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.UUID;

// Spring Data JPA generates the implementation at runtime
public interface ExpenseRepository extends JpaRepository<Expense, UUID> {

    // Always filter by userId for sharding-ready access patterns
    List<Expense> findByUserId(UUID userId);
}
  • Save ExpenseRepository.java.

Now you have the data layer ready. Next, build the service that actually talks to the LLM.

  • Create a new file ExpenseAnalysisService.java inside the core-service/src/main/java/com/nextwork/expense/service/ package.
  • Add the following service code:
package com.nextwork.expense.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.nextwork.expense.dto.ExpenseAnalysisResult;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class ExpenseAnalysisService {

    private final ChatClient chatClient;
    private final ObjectMapper objectMapper;

    // Spring AI auto-configures a ChatClient.Builder bean
    public ExpenseAnalysisService(ChatClient.Builder chatClientBuilder, ObjectMapper objectMapper) {
        this.chatClient = chatClientBuilder.build();
        this.objectMapper = objectMapper;
    }

    /**
     * Sends OCR text to the LLM and parses the structured JSON response.
     */
    public ExpenseAnalysisResult analyzeExpense(String ocrText) {
        // Build a prompt that instructs the LLM to return JSON
        String prompt = """
            Analyze the following invoice text and extract the expense details.
            Return ONLY a JSON object with these exact keys:
            - vendor (the company or person who issued the invoice)
            - amount (the total amount as a number, no currency symbol)
            - category (one of: FOOD, TRANSPORT, UTILITIES, ENTERTAINMENT, HEALTHCARE, OFFICE, OTHER)
            - expenseDate (the date in YYYY-MM-DD format)

            If you cannot determine a field, use "UNKNOWN" for strings and 0 for amount.

            Invoice text:
            %s
            """.formatted(ocrText);

        // Call the LLM and get the response as a String
        String response = chatClient.prompt()
                .user(prompt)
                .call()
                .content();

        // Parse the JSON response into the typed DTO
        try {
            return objectMapper.readValue(response, ExpenseAnalysisResult.class);
        } catch (Exception e) {
            throw new RuntimeException("Failed to parse LLM response: " + response, e);
        }
    }
}

What does this service do?

  • The constructor injects a ChatClient.Builder (auto-configured by Spring AI) and builds a ChatClient instance.
  • The prompt uses a text block with explicit JSON instructions. This technique is called structured prompting. It guides the LLM to return machine-parseable output.
  • chatClient.prompt().user(prompt).call().content() sends the prompt to Ollama and returns the raw response string.
  • Jackson's ObjectMapper deserializes the JSON string into your ExpenseAnalysisResult record.

Why use a local LLM instead of a cloud API?

Ollama runs entirely on your machine. There are no rate limits, no API keys, and no costs. Your data never leaves your laptop.

Spring AI's ChatClient interface is provider-agnostic. In production, you swap to a cloud LLM by changing one dependency and one config property. The service code stays identical.

  • Save ExpenseAnalysisService.java.

Seeing a bean creation error for ChatClient.Builder?

  • Confirm that spring-ai-starter-model-ollama is in your pom.xml dependencies and that the Spring AI BOM is declared in your dependency management section.
  • Verify your application.yml has the spring.ai.ollama properties set correctly under the right indentation level.

Why is Spring AI not creating a ChatClient.Builder bean?

Wire the AI analysis into the invoice upload flow

The upload endpoint currently saves OCR text and stops. You need to extend it so that after OCR, the text flows to the AI service, and the parsed result is saved as an Expense record.

  • Open InvoiceController.java in the controller package.
  • Replace the entire class with the following updated version:
package com.nextwork.expense.controller;

import com.nextwork.expense.dto.ExpenseAnalysisResult;
import com.nextwork.expense.entity.Expense;
import com.nextwork.expense.entity.Invoice;
import com.nextwork.expense.entity.User;
import com.nextwork.expense.repository.ExpenseRepository;
import com.nextwork.expense.repository.InvoiceRepository;
import com.nextwork.expense.repository.UserRepository;
import com.nextwork.expense.service.ExpenseAnalysisService;
import com.nextwork.expense.service.InvoiceOcrService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.time.LocalDate;
import java.util.Map;

@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {

    private final InvoiceOcrService invoiceOcrService;
    private final ExpenseAnalysisService expenseAnalysisService;
    private final InvoiceRepository invoiceRepository;
    private final ExpenseRepository expenseRepository;
    private final UserRepository userRepository;

    public InvoiceController(InvoiceOcrService invoiceOcrService,
                             ExpenseAnalysisService expenseAnalysisService,
                             InvoiceRepository invoiceRepository,
                             ExpenseRepository expenseRepository,
                             UserRepository userRepository) {
        this.invoiceOcrService = invoiceOcrService;
        this.expenseAnalysisService = expenseAnalysisService;
        this.invoiceRepository = invoiceRepository;
        this.expenseRepository = expenseRepository;
        this.userRepository = userRepository;
    }

    @PostMapping("/upload")
    public ResponseEntity<?> uploadInvoice(@RequestParam("image") MultipartFile image) {
        // Get the authenticated user
        String email = SecurityContextHolder.getContext().getAuthentication().getName();
        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new RuntimeException("User not found"));

        // Step 1: Run OCR to extract text from the image
        String ocrText = invoiceOcrService.processImage(image);

        // Step 2: Save the invoice with status PROCESSING
        Invoice invoice = new Invoice();
        invoice.setUserId(user.getId());
        invoice.setRawOcrText(ocrText);
        invoice.setStatus("PROCESSING");
        invoice = invoiceRepository.save(invoice);

        // Step 3: Send OCR text to AI for structured analysis
        ExpenseAnalysisResult result = expenseAnalysisService.analyzeExpense(ocrText);

        // Step 4: Create and save the Expense entity from AI result
        Expense expense = new Expense();
        expense.setInvoiceId(invoice.getId());
        expense.setUserId(user.getId());
        expense.setVendor(result.vendor());
        expense.setAmount(result.amount());
        expense.setCategory(result.category());
        expense.setExpenseDate(LocalDate.parse(result.expenseDate()));
        expenseRepository.save(expense);

        // Step 5: Mark invoice as completed
        invoice.setStatus("COMPLETED");
        invoiceRepository.save(invoice);

        // Return the full processing result
        return ResponseEntity.ok(Map.of(
                "invoiceId", invoice.getId(),
                "ocrText", ocrText,
                "vendor", result.vendor(),
                "amount", result.amount(),
                "category", result.category(),
                "expenseDate", result.expenseDate()
        ));
    }
}

What changed in the controller?

  • The controller now injects ExpenseAnalysisService and ExpenseRepository alongside the existing dependencies.
  • After OCR, the invoice status is set to PROCESSING (not PENDING) to signal that AI analysis is in progress.
  • The AI result is mapped into a new Expense entity and saved to the database.
  • Once everything succeeds, the invoice status becomes COMPLETED.
  • The response now includes both the raw OCR text and the structured expense fields.
  • Save InvoiceController.java.
  • Rebuild and run the application in IntelliJ.
  • Test the endpoint by sending a POST request to /api/invoices/upload with an invoice image. You should receive a JSON response containing invoiceId, ocrText, vendor, amount, category, and expenseDate fields.

First request may be slow

The first call to Ollama loads the model into GPU memory. This can take 10-30 seconds. Subsequent requests respond much faster because the model stays loaded.

Getting a parse error from the LLM response?

  • LLMs sometimes wrap JSON in markdown code fences (```json ... ```). If this happens, you can strip the fences before parsing. A production fix would add response cleaning logic.
  • Make sure Ollama is running the llama3.1:8b model. Run ollama list in your terminal to verify.
  • Check that your test image contains readable invoice text. Blurry or handwritten invoices produce poor OCR output that confuses the LLM.

Why is the LLM returning invalid JSON that ObjectMapper cannot parse?

✔️ Awesome, I've got everything!

Great. Double check you have saved all five files before moving on.

ⓧ I'd like to double check the full code

Here is the full code for each file at the end of this step.

spring:
  ai:
    ollama:
      base-url: http://localhost:11434
      chat:
        options:
          model: llama3.1:8b
package com.nextwork.expense.dto;

import java.math.BigDecimal;

// Holds the structured result returned by the LLM after analyzing OCR text
public record ExpenseAnalysisResult(
    String vendor,
    BigDecimal amount,
    String category,
    String expenseDate
) {}
package com.nextwork.expense.entity;

import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;

// Represents a single parsed expense extracted from an invoice
@Entity
@Table(name = "expenses")
public class Expense {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    // Links back to the source invoice
    @Column(name = "invoice_id")
    private UUID invoiceId;

    // Partition key for sharding-ready queries
    @Column(name = "user_id")
    private UUID userId;

    private String vendor;

    // Precision and scale match the database column definition
    @Column(precision = 10, scale = 2)
    private BigDecimal amount;

    private String category;

    @Column(name = "expense_date")
    private LocalDate expenseDate;

    @CreationTimestamp
    @Column(name = "created_at")
    private LocalDateTime createdAt;

    // Getters and setters
    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }

    public UUID getInvoiceId() { return invoiceId; }
    public void setInvoiceId(UUID invoiceId) { this.invoiceId = invoiceId; }

    public UUID getUserId() { return userId; }
    public void setUserId(UUID userId) { this.userId = userId; }

    public String getVendor() { return vendor; }
    public void setVendor(String vendor) { this.vendor = vendor; }

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

    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }

    public LocalDate getExpenseDate() { return expenseDate; }
    public void setExpenseDate(LocalDate expenseDate) { this.expenseDate = expenseDate; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
package com.nextwork.expense.repository;

import com.nextwork.expense.entity.Expense;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.UUID;

// Spring Data JPA generates the implementation at runtime
public interface ExpenseRepository extends JpaRepository<Expense, UUID> {

    // Always filter by userId for sharding-ready access patterns
    List<Expense> findByUserId(UUID userId);
}
package com.nextwork.expense.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.nextwork.expense.dto.ExpenseAnalysisResult;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class ExpenseAnalysisService {

    private final ChatClient chatClient;
    private final ObjectMapper objectMapper;

    // Spring AI auto-configures a ChatClient.Builder bean
    public ExpenseAnalysisService(ChatClient.Builder chatClientBuilder, ObjectMapper objectMapper) {
        this.chatClient = chatClientBuilder.build();
        this.objectMapper = objectMapper;
    }

    /**
     * Sends OCR text to the LLM and parses the structured JSON response.
     */
    public ExpenseAnalysisResult analyzeExpense(String ocrText) {
        // Build a prompt that instructs the LLM to return JSON
        String prompt = """
            Analyze the following invoice text and extract the expense details.
            Return ONLY a JSON object with these exact keys:
            - vendor (the company or person who issued the invoice)
            - amount (the total amount as a number, no currency symbol)
            - category (one of: FOOD, TRANSPORT, UTILITIES, ENTERTAINMENT, HEALTHCARE, OFFICE, OTHER)
            - expenseDate (the date in YYYY-MM-DD format)

            If you cannot determine a field, use "UNKNOWN" for strings and 0 for amount.

            Invoice text:
            %s
            """.formatted(ocrText);

        // Call the LLM and get the response as a String
        String response = chatClient.prompt()
                .user(prompt)
                .call()
                .content();

        // Parse the JSON response into the typed DTO
        try {
            return objectMapper.readValue(response, ExpenseAnalysisResult.class);
        } catch (Exception e) {
            throw new RuntimeException("Failed to parse LLM response: " + response, e);
        }
    }
}
package com.nextwork.expense.controller;

import com.nextwork.expense.dto.ExpenseAnalysisResult;
import com.nextwork.expense.entity.Expense;
import com.nextwork.expense.entity.Invoice;
import com.nextwork.expense.entity.User;
import com.nextwork.expense.repository.ExpenseRepository;
import com.nextwork.expense.repository.InvoiceRepository;
import com.nextwork.expense.repository.UserRepository;
import com.nextwork.expense.service.ExpenseAnalysisService;
import com.nextwork.expense.service.InvoiceOcrService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.time.LocalDate;
import java.util.Map;

@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {

    private final InvoiceOcrService invoiceOcrService;
    private final ExpenseAnalysisService expenseAnalysisService;
    private final InvoiceRepository invoiceRepository;
    private final ExpenseRepository expenseRepository;
    private final UserRepository userRepository;

    public InvoiceController(InvoiceOcrService invoiceOcrService,
                             ExpenseAnalysisService expenseAnalysisService,
                             InvoiceRepository invoiceRepository,
                             ExpenseRepository expenseRepository,
                             UserRepository userRepository) {
        this.invoiceOcrService = invoiceOcrService;
        this.expenseAnalysisService = expenseAnalysisService;
        this.invoiceRepository = invoiceRepository;
        this.expenseRepository = expenseRepository;
        this.userRepository = userRepository;
    }

    @PostMapping("/upload")
    public ResponseEntity<?> uploadInvoice(@RequestParam("image") MultipartFile image) {
        // Get the authenticated user
        String email = SecurityContextHolder.getContext().getAuthentication().getName();
        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new RuntimeException("User not found"));

        // Step 1: Run OCR to extract text from the image
        String ocrText = invoiceOcrService.processImage(image);

        // Step 2: Save the invoice with status PROCESSING
        Invoice invoice = new Invoice();
        invoice.setUserId(user.getId());
        invoice.setRawOcrText(ocrText);
        invoice.setStatus("PROCESSING");
        invoice = invoiceRepository.save(invoice);

        // Step 3: Send OCR text to AI for structured analysis
        ExpenseAnalysisResult result = expenseAnalysisService.analyzeExpense(ocrText);

        // Step 4: Create and save the Expense entity from AI result
        Expense expense = new Expense();
        expense.setInvoiceId(invoice.getId());
        expense.setUserId(user.getId());
        expense.setVendor(result.vendor());
        expense.setAmount(result.amount());
        expense.setCategory(result.category());
        expense.setExpenseDate(LocalDate.parse(result.expenseDate()));
        expenseRepository.save(expense);

        // Step 5: Mark invoice as completed
        invoice.setStatus("COMPLETED");
        invoiceRepository.save(invoice);

        // Return the full processing result
        return ResponseEntity.ok(Map.of(
                "invoiceId", invoice.getId(),
                "ocrText", ocrText,
                "vendor", result.vendor(),
                "amount", result.amount(),
                "category", result.category(),
                "expenseDate", result.expenseDate()
        ));
    }
}

Your invoice pipeline now goes from image to structured data in a single API call. Next up, you will make this pipeline non-blocking with RabbitMQ so uploads return instantly while processing happens in the background.

Add Async Processing with RabbitMQ

Your invoice upload endpoint currently does everything synchronously. The user uploads an image, waits while OCR runs, waits while the LLM analyzes the text, and only then gets a response. In production, this creates a problem: if the LLM takes 30 seconds to respond, the HTTP connection sits open the entire time.

The solution is asynchronous message processing. Instead of doing all the work during the HTTP request, you publish a message to a queue and return immediately. A background consumer picks up the message and does the heavy lifting. This is the pattern behind every high-scale fintech system.

In this step, get ready to:
  • Configure Spring AMQP with a queue, exchange, and binding for invoice processing.
  • Refactor the upload endpoint to publish a message and return immediately with 202 Accepted.
  • Build a consumer that picks up messages and runs the OCR + AI pipeline in the background.
Configure RabbitMQ with Spring AMQP

RabbitMQ uses three building blocks to route messages: a queue (where messages wait to be consumed), an exchange (the entry point that receives messages from producers), and a binding (the rule connecting the exchange to the queue using a routing key).

  • Create a new file InvoiceProcessingMessage.java inside core-service/src/main/java/com/nextwork/expense/dto/ by pasting this code:
package com.nextwork.expense.dto;

import java.util.UUID;

// Message payload sent to the invoice processing queue
public record InvoiceProcessingMessage(
    UUID invoiceId,
    String filePath,
    UUID userId
) {}

What does this code do?

This record defines the data that travels through the message queue. When the upload endpoint publishes a message, it includes the invoice ID (so the consumer can find the database record), the file path (so the consumer can load the image), and the user ID (so the expense gets linked to the right user.

  • Save InvoiceProcessingMessage.java.
  • Create a new file RabbitMQConfig.java inside core-service/src/main/java/com/nextwork/expense/config/ by pasting this code:
package com.nextwork.expense.config;

import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitMQConfig {

    // Constants for queue, exchange, and routing key names
    public static final String QUEUE_NAME = "invoice-processing";
    public static final String EXCHANGE_NAME = "invoice-exchange";
    public static final String ROUTING_KEY = "invoice.process";

    @Bean
    public Queue invoiceProcessingQueue() {
        // Durable queue survives broker restarts
        return new Queue(QUEUE_NAME, true);
    }

    @Bean
    public DirectExchange invoiceExchange() {
        return new DirectExchange(EXCHANGE_NAME);
    }

    @Bean
    public Binding invoiceBinding(Queue invoiceProcessingQueue, DirectExchange invoiceExchange) {
        // Bind the queue to the exchange with the routing key
        return BindingBuilder.bind(invoiceProcessingQueue).to(invoiceExchange).with(ROUTING_KEY);
    }

    @Bean
    public Jackson2JsonMessageConverter messageConverter() {
        // Serialize messages as JSON for readable queue contents
        return new Jackson2JsonMessageConverter();
    }

    @Bean
    public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, Jackson2JsonMessageConverter messageConverter) {
        RabbitTemplate template = new RabbitTemplate(connectionFactory);
        template.setMessageConverter(messageConverter);
        return template;
    }
}

What does this configuration do?

This class declares the RabbitMQ infrastructure as Spring beans so they are created automatically on startup.

  • The Queue is durable (second argument true), meaning messages survive a RabbitMQ restart.
  • The DirectExchange routes messages to queues based on an exact routing key match.
  • The Jackson2JsonMessageConverter serializes your Java records as JSON so messages are human-readable in the RabbitMQ management UI.
  • Save RabbitMQConfig.java.
  • Verify your RabbitMQ Docker container is running by opening http://localhost:15672 in your browser. You should see the RabbitMQ management login page.

Can't reach the RabbitMQ management UI?

Make sure your Docker Compose stack is running. The management UI runs on port 15672 while the AMQP protocol uses port 5672. Check both ports are mapped in your docker-compose.yml.

Help me troubleshoot why my RabbitMQ container is not accessible on localhost:15672

Refactor the upload endpoint to publish instead of process

The current upload endpoint does everything inline: OCR, AI analysis, saving results. You will now refactor it to save the file, create a PENDING invoice, publish a message, and return immediately.

  • Open InvoiceController.java and replace the entire file contents with this code:
package com.nextwork.expense.controller;

import com.nextwork.expense.config.RabbitMQConfig;
import com.nextwork.expense.dto.InvoiceProcessingMessage;
import com.nextwork.expense.entity.Invoice;
import com.nextwork.expense.repository.InvoiceRepository;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;

@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {

    private final InvoiceRepository invoiceRepository;
    private final RabbitTemplate rabbitTemplate;

    public InvoiceController(InvoiceRepository invoiceRepository, RabbitTemplate rabbitTemplate) {
        this.invoiceRepository = invoiceRepository;
        this.rabbitTemplate = rabbitTemplate;
    }

    @PostMapping("/upload")
    public ResponseEntity<?> uploadInvoice(
            @RequestParam("file") MultipartFile file,
            @AuthenticationPrincipal UserDetails userDetails) throws IOException {

        // Save uploaded file to a temp location for async processing
        String filename = UUID.randomUUID() + "_" + file.getOriginalFilename();
        Path tempPath = Path.of(System.getProperty("java.io.tmpdir"), filename);
        Files.copy(file.getInputStream(), tempPath);

        // Create invoice record with PENDING status
        Invoice invoice = new Invoice();
        invoice.setUserId(UUID.fromString(userDetails.getUsername()));
        invoice.setStatus("PENDING");
        invoice = invoiceRepository.save(invoice);

        // Publish message to RabbitMQ for async processing
        InvoiceProcessingMessage message = new InvoiceProcessingMessage(
            invoice.getId(),
            tempPath.toString(),
            invoice.getUserId()
        );
        rabbitTemplate.convertAndSend(
            RabbitMQConfig.EXCHANGE_NAME,
            RabbitMQConfig.ROUTING_KEY,
            message
        );

        // Return immediately with 202 Accepted
        return ResponseEntity.accepted().body(Map.of("invoiceId", invoice.getId()));
    }
}

What changed in the controller?

The old controller injected InvoiceOcrService and ExpenseAnalysisService to do all the work synchronously. The new version only needs InvoiceRepository and RabbitTemplate.

  • The file is saved to the system temp directory so the background consumer can access it later.
  • The invoice starts with status PENDING instead of PROCESSING because no work has started yet.
  • The response is HTTP 202 Accepted (not 200 OK), signaling that the request was received but processing has not finished.

Why async over synchronous?

In a synchronous flow, the client is blocked until the server finishes all work. If OCR takes 5 seconds and the LLM takes 20 seconds, the HTTP request sits open for 25 seconds. This ties up a server thread and risks timeouts.

With async processing, the endpoint returns in milliseconds. The heavy work happens in the background. This is the same pattern that banks, payment processors, and high-scale APIs use to handle millions of requests without blocking.

  • Save InvoiceController.java.

Seeing compilation errors after the refactor?

If IntelliJ shows red underlines on InvoiceProcessingMessage, make sure you saved the DTO file from the previous substep. Also confirm the import path matches your package structure: com.nextwork.expense.dto.InvoiceProcessingMessage.

Help me fix compilation errors in my InvoiceController after refactoring to use RabbitTemplate

Build the message consumer

The producer (your controller) publishes messages. Now you need a consumer that listens on the queue and does the actual work. Spring AMQP makes this simple with the @RabbitListener annotation.

  • First, add a new method to InvoiceOcrService.java that accepts a File directly (the consumer works with files on disk, not multipart uploads). Add this method below the existing processImage method:
// Process OCR from a file on disk (used by async consumer)
public String processImageFromFile(File imageFile) throws TesseractException {
    return tesseract.doOCR(imageFile);
}

Why a separate method?

The existing processImage(MultipartFile) method handles HTTP uploads. The consumer works with a File path on disk. Tess4J's doOCR(File) accepts a File directly, so this method is a thin wrapper.

  • Save InvoiceOcrService.java.
  • Create a new package messaging inside core-service/src/main/java/com/nextwork/expense/ and create a file called InvoiceProcessingConsumer.java with this code:
package com.nextwork.expense.messaging;

import com.nextwork.expense.dto.ExpenseAnalysisResult;
import com.nextwork.expense.dto.InvoiceProcessingMessage;
import com.nextwork.expense.entity.Expense;
import com.nextwork.expense.entity.Invoice;
import com.nextwork.expense.repository.ExpenseRepository;
import com.nextwork.expense.repository.InvoiceRepository;
import com.nextwork.expense.service.ExpenseAnalysisService;
import com.nextwork.expense.service.InvoiceOcrService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Component;

import java.io.File;
import java.time.LocalDate;

@Component
public class InvoiceProcessingConsumer {

    private static final Logger log = LoggerFactory.getLogger(InvoiceProcessingConsumer.class);

    private final InvoiceRepository invoiceRepository;
    private final ExpenseRepository expenseRepository;
    private final InvoiceOcrService invoiceOcrService;
    private final ExpenseAnalysisService expenseAnalysisService;

    public InvoiceProcessingConsumer(
            InvoiceRepository invoiceRepository,
            ExpenseRepository expenseRepository,
            InvoiceOcrService invoiceOcrService,
            ExpenseAnalysisService expenseAnalysisService) {
        this.invoiceRepository = invoiceRepository;
        this.expenseRepository = expenseRepository;
        this.invoiceOcrService = invoiceOcrService;
        this.expenseAnalysisService = expenseAnalysisService;
    }

    @RabbitListener(queues = "invoice-processing")
    public void processInvoice(@Payload InvoiceProcessingMessage message) {
        try {
            // Mark invoice as PROCESSING
            Invoice invoice = invoiceRepository.findById(message.invoiceId())
                .orElseThrow(() -> new RuntimeException("Invoice not found: " + message.invoiceId()));
            invoice.setStatus("PROCESSING");
            invoiceRepository.save(invoice);

            // Run OCR on the saved image file
            File imageFile = new File(message.filePath());
            String ocrText = invoiceOcrService.processImageFromFile(imageFile);

            // Analyze extracted text with AI
            ExpenseAnalysisResult result = expenseAnalysisService.analyzeExpense(ocrText);

            // Create and save the expense record
            Expense expense = new Expense();
            expense.setInvoiceId(message.invoiceId());
            expense.setUserId(message.userId());
            expense.setVendor(result.vendor());
            expense.setAmount(result.amount());
            expense.setCategory(result.category());
            expense.setExpenseDate(LocalDate.parse(result.expenseDate()));
            expenseRepository.save(expense);

            // Mark invoice as COMPLETED
            invoice.setStatus("COMPLETED");
            invoiceRepository.save(invoice);

            log.info("Successfully processed invoice: {}", message.invoiceId());
        } catch (Exception e) {
            // Mark invoice as FAILED on any error
            log.error("Failed to process invoice: {}", message.invoiceId(), e);
            invoiceRepository.findById(message.invoiceId()).ifPresent(invoice -> {
                invoice.setStatus("FAILED");
                invoiceRepository.save(invoice);
            });
        }
    }
}

What does the consumer do?

  • The @RabbitListener(queues = "invoice-processing") annotation tells Spring to call this method whenever a message arrives on the queue.
  • The status flow is PENDING (set by controller) to PROCESSING (set when consumer starts) to COMPLETED or FAILED. This gives the frontend a way to poll for progress.
  • The entire processing block is wrapped in a try/catch so that failures update the invoice status to FAILED instead of silently disappearing.
  • Save InvoiceProcessingConsumer.java.
  • Run your Spring Boot application.
  • In the console output, look for a log line containing Started container or Created new connection referencing RabbitMQ. This confirms your app connected to the broker and registered the listener.

Test the async flow by uploading an invoice image with a tool like Postman or curl. You should get a 202 response with an invoice ID within milliseconds. The OCR and AI processing happens in the background.

Monitor messages in the RabbitMQ UI

Open http://localhost:15672 and log in with the default credentials (guest/guest). Click the Queues tab to see your invoice-processing queue with message counts and throughput.

Consumer not picking up messages?

Check that your application.yml has the RabbitMQ connection settings (spring.rabbitmq.host: localhost and spring.rabbitmq.port: 5672). Also verify the queue name in @RabbitListener matches the constant in RabbitMQConfig exactly.

Help me debug why my @RabbitListener consumer is not receiving messages from the invoice-processing queue

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: InvoiceProcessingMessage.java, RabbitMQConfig.java, InvoiceController.java, and InvoiceProcessingConsumer.java.

ⓧ I'd like to double check the full code

Here are the complete files for this step.

package com.nextwork.expense.dto;

import java.util.UUID;

// Message payload sent to the invoice processing queue
public record InvoiceProcessingMessage(
    UUID invoiceId,
    String filePath,
    UUID userId
) {}
package com.nextwork.expense.config;

import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitMQConfig {

    // Constants for queue, exchange, and routing key names
    public static final String QUEUE_NAME = "invoice-processing";
    public static final String EXCHANGE_NAME = "invoice-exchange";
    public static final String ROUTING_KEY = "invoice.process";

    @Bean
    public Queue invoiceProcessingQueue() {
        // Durable queue survives broker restarts
        return new Queue(QUEUE_NAME, true);
    }

    @Bean
    public DirectExchange invoiceExchange() {
        return new DirectExchange(EXCHANGE_NAME);
    }

    @Bean
    public Binding invoiceBinding(Queue invoiceProcessingQueue, DirectExchange invoiceExchange) {
        // Bind the queue to the exchange with the routing key
        return BindingBuilder.bind(invoiceProcessingQueue).to(invoiceExchange).with(ROUTING_KEY);
    }

    @Bean
    public Jackson2JsonMessageConverter messageConverter() {
        // Serialize messages as JSON for readable queue contents
        return new Jackson2JsonMessageConverter();
    }

    @Bean
    public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, Jackson2JsonMessageConverter messageConverter) {
        RabbitTemplate template = new RabbitTemplate(connectionFactory);
        template.setMessageConverter(messageConverter);
        return template;
    }
}
package com.nextwork.expense.controller;

import com.nextwork.expense.config.RabbitMQConfig;
import com.nextwork.expense.dto.InvoiceProcessingMessage;
import com.nextwork.expense.entity.Invoice;
import com.nextwork.expense.repository.InvoiceRepository;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;

@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {

    private final InvoiceRepository invoiceRepository;
    private final RabbitTemplate rabbitTemplate;

    public InvoiceController(InvoiceRepository invoiceRepository, RabbitTemplate rabbitTemplate) {
        this.invoiceRepository = invoiceRepository;
        this.rabbitTemplate = rabbitTemplate;
    }

    @PostMapping("/upload")
    public ResponseEntity<?> uploadInvoice(
            @RequestParam("file") MultipartFile file,
            @AuthenticationPrincipal UserDetails userDetails) throws IOException {

        // Save uploaded file to a temp location for async processing
        String filename = UUID.randomUUID() + "_" + file.getOriginalFilename();
        Path tempPath = Path.of(System.getProperty("java.io.tmpdir"), filename);
        Files.copy(file.getInputStream(), tempPath);

        // Create invoice record with PENDING status
        Invoice invoice = new Invoice();
        invoice.setUserId(UUID.fromString(userDetails.getUsername()));
        invoice.setStatus("PENDING");
        invoice = invoiceRepository.save(invoice);

        // Publish message to RabbitMQ for async processing
        InvoiceProcessingMessage message = new InvoiceProcessingMessage(
            invoice.getId(),
            tempPath.toString(),
            invoice.getUserId()
        );
        rabbitTemplate.convertAndSend(
            RabbitMQConfig.EXCHANGE_NAME,
            RabbitMQConfig.ROUTING_KEY,
            message
        );

        // Return immediately with 202 Accepted
        return ResponseEntity.accepted().body(Map.of("invoiceId", invoice.getId()));
    }
}
package com.nextwork.expense.messaging;

import com.nextwork.expense.dto.ExpenseAnalysisResult;
import com.nextwork.expense.dto.InvoiceProcessingMessage;
import com.nextwork.expense.entity.Expense;
import com.nextwork.expense.entity.Invoice;
import com.nextwork.expense.repository.ExpenseRepository;
import com.nextwork.expense.repository.InvoiceRepository;
import com.nextwork.expense.service.ExpenseAnalysisService;
import com.nextwork.expense.service.InvoiceOcrService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Component;

import java.io.File;
import java.time.LocalDate;

@Component
public class InvoiceProcessingConsumer {

    private static final Logger log = LoggerFactory.getLogger(InvoiceProcessingConsumer.class);

    private final InvoiceRepository invoiceRepository;
    private final ExpenseRepository expenseRepository;
    private final InvoiceOcrService invoiceOcrService;
    private final ExpenseAnalysisService expenseAnalysisService;

    public InvoiceProcessingConsumer(
            InvoiceRepository invoiceRepository,
            ExpenseRepository expenseRepository,
            InvoiceOcrService invoiceOcrService,
            ExpenseAnalysisService expenseAnalysisService) {
        this.invoiceRepository = invoiceRepository;
        this.expenseRepository = expenseRepository;
        this.invoiceOcrService = invoiceOcrService;
        this.expenseAnalysisService = expenseAnalysisService;
    }

    @RabbitListener(queues = "invoice-processing")
    public void processInvoice(@Payload InvoiceProcessingMessage message) {
        try {
            // Mark invoice as PROCESSING
            Invoice invoice = invoiceRepository.findById(message.invoiceId())
                .orElseThrow(() -> new RuntimeException("Invoice not found: " + message.invoiceId()));
            invoice.setStatus("PROCESSING");
            invoiceRepository.save(invoice);

            // Run OCR on the saved image file
            File imageFile = new File(message.filePath());
            String ocrText = invoiceOcrService.processImageFromFile(imageFile);

            // Analyze extracted text with AI
            ExpenseAnalysisResult result = expenseAnalysisService.analyzeExpense(ocrText);

            // Create and save the expense record
            Expense expense = new Expense();
            expense.setInvoiceId(message.invoiceId());
            expense.setUserId(message.userId());
            expense.setVendor(result.vendor());
            expense.setAmount(result.amount());
            expense.setCategory(result.category());
            expense.setExpenseDate(LocalDate.parse(result.expenseDate()));
            expenseRepository.save(expense);

            // Mark invoice as COMPLETED
            invoice.setStatus("COMPLETED");
            invoiceRepository.save(invoice);

            log.info("Successfully processed invoice: {}", message.invoiceId());
        } catch (Exception e) {
            // Mark invoice as FAILED on any error
            log.error("Failed to process invoice: {}", message.invoiceId(), e);
            invoiceRepository.findById(message.invoiceId()).ifPresent(invoice -> {
                invoice.setStatus("FAILED");
                invoiceRepository.save(invoice);
            });
        }
    }
}

Your invoice pipeline now runs asynchronously. The upload endpoint returns instantly while the heavy processing happens in the background. Next up, you will add a Redis caching layer so repeated analytics queries hit memory instead of the database.

Add Redis Caching for Analytics

Your async pipeline is humming along, processing invoices in the background. Now you need to surface that data to users through analytics endpoints.

Analytics queries aggregate data across potentially thousands of expense records. Running those queries on every single dashboard refresh wastes database resources. In this step, you'll add a Redis caching layer that stores query results and serves them instantly on repeat requests.

In this step, get ready to:
  • Configure Redis as the Spring Cache backend with JSON serialization and a 10-minute TTL.
  • Build an analytics service with cached category and monthly spending queries.
  • Wire cache eviction into the processing pipeline so new expenses always invalidate stale data.
Configure the Redis caching layer

Spring's Cache abstraction intercepts method calls and returns cached results when a matching entry exists. You need to enable this mechanism and tell Spring to use Redis as the backing store instead of the default in-memory map.

  • Open SmartExpenseAnalyzerApplication.java (your main application class).
  • Add the @EnableCaching annotation to the class, so the top of the file looks like this:
package com.nextwork.expense;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;

@SpringBootApplication
@EnableCaching
public class SmartExpenseAnalyzerApplication {

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

What does @EnableCaching do?

This annotation activates Spring's caching infrastructure. It tells Spring to scan for @Cacheable, @CacheEvict, and other cache annotations on your beans, and intercepts those method calls to check Redis before running the actual logic.

  • Now create a new file RedisConfig.java inside core-service/src/main/java/com/nextwork/expense/config/.
  • Add the following configuration class:
package com.nextwork.expense.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;

import java.time.Duration;

@Configuration
public class RedisConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        // Configure default cache behaviour: 10-minute TTL with JSON serialization
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair
                                .fromSerializer(new GenericJackson2JsonRedisSerializer())
                );

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(config)
                .build();
    }
}

What does this configuration do?

entryTtl(Duration.ofMinutes(10)) sets cached entries to expire after 10 minutes. This means stale analytics data never lives longer than 10 minutes even without explicit eviction.

GenericJackson2JsonRedisSerializer stores cache values as human-readable JSON instead of opaque Java-serialized bytes. This makes debugging with Redis CLI much easier.

  • Save both files.

Seeing a bean conflict error on startup?

If Spring reports a duplicate CacheManager bean, make sure you only have one cacheManager method across all your configuration classes. Spring Boot auto-configures one if Redis is on the classpath, but your custom bean takes priority.

help me fix a CacheManager bean conflict in my Spring Boot Redis caching setup

Build the analytics service with cached queries

The analytics feature needs three layers: repository queries that aggregate raw data, DTOs that shape the response, and a service that orchestrates caching. You will build all three, starting from the bottom up.

  • Open ExpenseRepository.java and add these two JPQL aggregate query methods:
package com.nextwork.expense.repository;

import com.nextwork.expense.entity.Expense;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.UUID;

public interface ExpenseRepository extends JpaRepository<Expense, UUID> {

    // Aggregate total spending per category for a given user
    @Query("SELECT e.category, SUM(e.amount) FROM Expense e WHERE e.userId = :userId GROUP BY e.category")
    List<Object[]> findCategoryTotalsByUserId(@Param("userId") UUID userId);

    // Aggregate total spending per month for a given user
    @Query("SELECT YEAR(e.expenseDate), MONTH(e.expenseDate), SUM(e.amount) FROM Expense e WHERE e.userId = :userId GROUP BY YEAR(e.expenseDate), MONTH(e.expenseDate) ORDER BY YEAR(e.expenseDate), MONTH(e.expenseDate)")
    List<Object[]> findMonthlyTotalsByUserId(@Param("userId") UUID userId);
}

What do these queries return?

findCategoryTotalsByUserId returns rows like ["Groceries", 245.50]. Each row is an Object[] where index 0 is the category name and index 1 is the summed amount.

findMonthlyTotalsByUserId returns rows like [2026, 5, 890.00]. Index 0 is the year, index 1 is the month number, and index 2 is the summed amount.

  • Save ExpenseRepository.java.
  • Create three DTO record files inside core-service/src/main/java/com/nextwork/expense/dto/.
  • First, create CategoryTotalDTO.java:
package com.nextwork.expense.dto;

import java.math.BigDecimal;

// Represents total spending for a single expense category
public record CategoryTotalDTO(String category, BigDecimal total) {}
  • Create MonthlyTotalDTO.java:
package com.nextwork.expense.dto;

import java.math.BigDecimal;

// Represents total spending for a single month
public record MonthlyTotalDTO(int year, int month, BigDecimal total) {}
  • Create SummaryDTO.java:
package com.nextwork.expense.dto;

import java.math.BigDecimal;

// High-level spending summary: total, top category, and month-over-month change
public record SummaryDTO(BigDecimal totalSpending, String topCategory, BigDecimal monthOverMonthChange) {}

Why use Java records for DTOs?

Records automatically generate constructors, getters, equals(), hashCode(), and toString(). They are immutable by design, making them perfect for data transfer objects that just carry values.

  • Save all three DTO files.
  • Now create AnalyticsService.java inside core-service/src/main/java/com/nextwork/expense/service/:
package com.nextwork.expense.service;

import com.nextwork.expense.dto.CategoryTotalDTO;
import com.nextwork.expense.dto.MonthlyTotalDTO;
import com.nextwork.expense.dto.SummaryDTO;
import com.nextwork.expense.repository.ExpenseRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;

@Service
public class AnalyticsService {

    private final ExpenseRepository expenseRepository;

    public AnalyticsService(ExpenseRepository expenseRepository) {
        this.expenseRepository = expenseRepository;
    }

    // Cache category breakdown per user - key includes userId and a suffix
    @Cacheable(value = "analytics", key = "#userId + ':categories'")
    public List<CategoryTotalDTO> getCategoryBreakdown(UUID userId) {
        List<Object[]> results = expenseRepository.findCategoryTotalsByUserId(userId);
        return results.stream()
                .map(row -> new CategoryTotalDTO((String) row[0], (BigDecimal) row[1]))
                .toList();
    }

    // Cache monthly breakdown per user
    @Cacheable(value = "analytics", key = "#userId + ':monthly'")
    public List<MonthlyTotalDTO> getMonthlyBreakdown(UUID userId) {
        List<Object[]> results = expenseRepository.findMonthlyTotalsByUserId(userId);
        return results.stream()
                .map(row -> new MonthlyTotalDTO((int) row[0], (int) row[1], (BigDecimal) row[2]))
                .toList();
    }

    // Summary is computed from the cached methods above
    public SummaryDTO getSummary(UUID userId) {
        List<CategoryTotalDTO> categories = getCategoryBreakdown(userId);
        List<MonthlyTotalDTO> monthly = getMonthlyBreakdown(userId);

        // Calculate total spending across all categories
        BigDecimal totalSpending = categories.stream()
                .map(CategoryTotalDTO::total)
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        // Find the category with the highest total
        String topCategory = categories.stream()
                .max(Comparator.comparing(CategoryTotalDTO::total))
                .map(CategoryTotalDTO::category)
                .orElse("N/A");

        // Calculate month-over-month percentage change
        BigDecimal monthOverMonthChange = calculateMonthOverMonthChange(monthly);

        return new SummaryDTO(totalSpending, topCategory, monthOverMonthChange);
    }

    // Evict both cache keys for a user when new data arrives
    @Caching(evict = {
            @CacheEvict(value = "analytics", key = "#userId + ':categories'"),
            @CacheEvict(value = "analytics", key = "#userId + ':monthly'")
    })
    public void evictAnalyticsCache(UUID userId) {
        // Spring handles eviction via the annotations
    }

    private BigDecimal calculateMonthOverMonthChange(List<MonthlyTotalDTO> monthly) {
        if (monthly.size() < 2) {
            return BigDecimal.ZERO;
        }
        // Compare the two most recent months
        BigDecimal current = monthly.get(monthly.size() - 1).total();
        BigDecimal previous = monthly.get(monthly.size() - 2).total();

        if (previous.compareTo(BigDecimal.ZERO) == 0) {
            return BigDecimal.ZERO;
        }

        return current.subtract(previous)
                .divide(previous, 2, RoundingMode.HALF_UP)
                .multiply(new BigDecimal("100"));
    }
}

What does this service do?

@Cacheable(value = "analytics", key = "#userId + ':categories'") tells Spring to check Redis for a cached result before running the method. The key combines the user's ID with a suffix so each user gets their own cache entry.

getSummary() delegates to the cached methods. Because those calls pass through Spring's proxy, they hit Redis first. No extra database queries.

evictAnalyticsCache() uses the @Caching annotation to combine two @CacheEvict operations. Both the categories and monthly cache entries for that user are cleared in one call.

  • Save AnalyticsService.java.
  • Create AnalyticsController.java inside core-service/src/main/java/com/nextwork/expense/controller/:
package com.nextwork.expense.controller;

import com.nextwork.expense.dto.CategoryTotalDTO;
import com.nextwork.expense.dto.MonthlyTotalDTO;
import com.nextwork.expense.dto.SummaryDTO;
import com.nextwork.expense.service.AnalyticsService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.UUID;

@RestController
@RequestMapping("/api/analytics")
public class AnalyticsController {

    private final AnalyticsService analyticsService;

    public AnalyticsController(AnalyticsService analyticsService) {
        this.analyticsService = analyticsService;
    }

    // Returns spending totals grouped by category
    @GetMapping("/categories")
    public ResponseEntity<List<CategoryTotalDTO>> getCategoryBreakdown(
            @AuthenticationPrincipal UserDetails userDetails) {
        UUID userId = UUID.fromString(userDetails.getUsername());
        return ResponseEntity.ok(analyticsService.getCategoryBreakdown(userId));
    }

    // Returns spending totals grouped by month
    @GetMapping("/monthly")
    public ResponseEntity<List<MonthlyTotalDTO>> getMonthlyBreakdown(
            @AuthenticationPrincipal UserDetails userDetails) {
        UUID userId = UUID.fromString(userDetails.getUsername());
        return ResponseEntity.ok(analyticsService.getMonthlyBreakdown(userId));
    }

    // Returns high-level spending summary
    @GetMapping("/summary")
    public ResponseEntity<SummaryDTO> getSummary(
            @AuthenticationPrincipal UserDetails userDetails) {
        UUID userId = UUID.fromString(userDetails.getUsername());
        return ResponseEntity.ok(analyticsService.getSummary(userId));
    }
}

What does this controller do?

Each endpoint extracts the authenticated user's ID from the JWT (via @AuthenticationPrincipal) and delegates to the analytics service. The first request hits PostgreSQL. Subsequent requests within 10 minutes are served from Redis.

  • Save AnalyticsController.java.
  • Rebuild your project. Confirm it compiles without errors.

Compilation errors in AnalyticsService?

Make sure your Expense entity has fields named category, amount, and expenseDate that match the JPQL queries exactly. Field names are case-sensitive in JPQL.

help me fix JPQL query errors in my ExpenseRepository

Wire cache eviction into the processing pipeline

Caching is only useful if the data stays consistent. When the invoice processing consumer saves a new expense, it must evict the user's cached analytics so the next request reflects the updated data.

  • Open InvoiceProcessingConsumer.java.
  • Inject AnalyticsService as a constructor dependency.
  • Find the line where you save the Expense entity (after expenseRepository.save(...)).
  • Add this line directly after saving the expense:
// Evict cached analytics so the next request reflects new data
analyticsService.evictAnalyticsCache(message.userId());

Why call eviction from a different bean?

Spring's cache annotations work through AOP proxies. If you called evictAnalyticsCache() from within AnalyticsService itself, the proxy would be bypassed and eviction would silently fail. Calling it from a different bean (the consumer) ensures the proxy intercepts the call and actually clears Redis.

  • Save InvoiceProcessingConsumer.java.
  • Rebuild your project and start the application.
  • Test the analytics endpoint by calling GET /api/analytics/categories with a valid JWT. You should see an empty array or your existing expense data.
  • Call the same endpoint a second time. The response should arrive noticeably faster because it is served from Redis.

Getting a 401 on the analytics endpoint?

Make sure you are passing a valid JWT in the Authorization: Bearer <token> header. Log in via your /api/auth/login endpoint first to get a fresh token.

Also confirm that /api/analytics/** is not excluded from your security filter chain. It should require authentication (not be in the permit-all list).

help me debug 401 errors on my analytics endpoints

✔️ Awesome, I've got everything!

Great. Double check that all your new files are saved and your project compiles cleanly.

ⓧ I'd like to double check the full code

Here are the key files for this step. Compare yours against these:

package com.nextwork.expense;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;

@SpringBootApplication
@EnableCaching
public class SmartExpenseAnalyzerApplication {

    public static void main(String[] args) {
        SpringApplication.run(SmartExpenseAnalyzerApplication.class, args);
    }
}
package com.nextwork.expense.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;

import java.time.Duration;

@Configuration
public class RedisConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair
                                .fromSerializer(new GenericJackson2JsonRedisSerializer())
                );

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(config)
                .build();
    }
}
package com.nextwork.expense.repository;

import com.nextwork.expense.entity.Expense;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.UUID;

public interface ExpenseRepository extends JpaRepository<Expense, UUID> {

    @Query("SELECT e.category, SUM(e.amount) FROM Expense e WHERE e.userId = :userId GROUP BY e.category")
    List<Object[]> findCategoryTotalsByUserId(@Param("userId") UUID userId);

    @Query("SELECT YEAR(e.expenseDate), MONTH(e.expenseDate), SUM(e.amount) FROM Expense e WHERE e.userId = :userId GROUP BY YEAR(e.expenseDate), MONTH(e.expenseDate) ORDER BY YEAR(e.expenseDate), MONTH(e.expenseDate)")
    List<Object[]> findMonthlyTotalsByUserId(@Param("userId") UUID userId);
}
package com.nextwork.expense.dto;

import java.math.BigDecimal;

public record CategoryTotalDTO(String category, BigDecimal total) {}
package com.nextwork.expense.dto;

import java.math.BigDecimal;

public record MonthlyTotalDTO(int year, int month, BigDecimal total) {}
package com.nextwork.expense.dto;

import java.math.BigDecimal;

public record SummaryDTO(BigDecimal totalSpending, String topCategory, BigDecimal monthOverMonthChange) {}
package com.nextwork.expense.service;

import com.nextwork.expense.dto.CategoryTotalDTO;
import com.nextwork.expense.dto.MonthlyTotalDTO;
import com.nextwork.expense.dto.SummaryDTO;
import com.nextwork.expense.repository.ExpenseRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;

@Service
public class AnalyticsService {

    private final ExpenseRepository expenseRepository;

    public AnalyticsService(ExpenseRepository expenseRepository) {
        this.expenseRepository = expenseRepository;
    }

    @Cacheable(value = "analytics", key = "#userId + ':categories'")
    public List<CategoryTotalDTO> getCategoryBreakdown(UUID userId) {
        List<Object[]> results = expenseRepository.findCategoryTotalsByUserId(userId);
        return results.stream()
                .map(row -> new CategoryTotalDTO((String) row[0], (BigDecimal) row[1]))
                .toList();
    }

    @Cacheable(value = "analytics", key = "#userId + ':monthly'")
    public List<MonthlyTotalDTO> getMonthlyBreakdown(UUID userId) {
        List<Object[]> results = expenseRepository.findMonthlyTotalsByUserId(userId);
        return results.stream()
                .map(row -> new MonthlyTotalDTO((int) row[0], (int) row[1], (BigDecimal) row[2]))
                .toList();
    }

    public SummaryDTO getSummary(UUID userId) {
        List<CategoryTotalDTO> categories = getCategoryBreakdown(userId);
        List<MonthlyTotalDTO> monthly = getMonthlyBreakdown(userId);

        BigDecimal totalSpending = categories.stream()
                .map(CategoryTotalDTO::total)
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        String topCategory = categories.stream()
                .max(Comparator.comparing(CategoryTotalDTO::total))
                .map(CategoryTotalDTO::category)
                .orElse("N/A");

        BigDecimal monthOverMonthChange = calculateMonthOverMonthChange(monthly);

        return new SummaryDTO(totalSpending, topCategory, monthOverMonthChange);
    }

    @Caching(evict = {
            @CacheEvict(value = "analytics", key = "#userId + ':categories'"),
            @CacheEvict(value = "analytics", key = "#userId + ':monthly'")
    })
    public void evictAnalyticsCache(UUID userId) {
    }

    private BigDecimal calculateMonthOverMonthChange(List<MonthlyTotalDTO> monthly) {
        if (monthly.size() < 2) {
            return BigDecimal.ZERO;
        }
        BigDecimal current = monthly.get(monthly.size() - 1).total();
        BigDecimal previous = monthly.get(monthly.size() - 2).total();

        if (previous.compareTo(BigDecimal.ZERO) == 0) {
            return BigDecimal.ZERO;
        }

        return current.subtract(previous)
                .divide(previous, 2, RoundingMode.HALF_UP)
                .multiply(new BigDecimal("100"));
    }
}
package com.nextwork.expense.controller;

import com.nextwork.expense.dto.CategoryTotalDTO;
import com.nextwork.expense.dto.MonthlyTotalDTO;
import com.nextwork.expense.dto.SummaryDTO;
import com.nextwork.expense.service.AnalyticsService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.UUID;

@RestController
@RequestMapping("/api/analytics")
public class AnalyticsController {

    private final AnalyticsService analyticsService;

    public AnalyticsController(AnalyticsService analyticsService) {
        this.analyticsService = analyticsService;
    }

    @GetMapping("/categories")
    public ResponseEntity<List<CategoryTotalDTO>> getCategoryBreakdown(
            @AuthenticationPrincipal UserDetails userDetails) {
        UUID userId = UUID.fromString(userDetails.getUsername());
        return ResponseEntity.ok(analyticsService.getCategoryBreakdown(userId));
    }

    @GetMapping("/monthly")
    public ResponseEntity<List<MonthlyTotalDTO>> getMonthlyBreakdown(
            @AuthenticationPrincipal UserDetails userDetails) {
        UUID userId = UUID.fromString(userDetails.getUsername());
        return ResponseEntity.ok(analyticsService.getMonthlyBreakdown(userId));
    }

    @GetMapping("/summary")
    public ResponseEntity<SummaryDTO> getSummary(
            @AuthenticationPrincipal UserDetails userDetails) {
        UUID userId = UUID.fromString(userDetails.getUsername());
        return ResponseEntity.ok(analyticsService.getSummary(userId));
    }
}

Your analytics are now lightning-fast on repeat requests. Next up, you'll add Resilience4j to protect the AI service calls with circuit breakers and rate limiting.

Apply Resilience4j Circuit Breaker and Rate Limiting

Your AI analysis service calls a local LLM that could become unresponsive. If Ollama crashes or gets overloaded, every invoice in your processing queue would fail and pile up.

In production fintech systems, architects wrap unreliable external calls with resilience patterns. A circuit breaker detects repeated failures and short-circuits requests to prevent cascading damage. A rate limiter caps how many requests hit an endpoint per second, protecting expensive operations from abuse.

In this step, get ready to:
  • Configure Resilience4j circuit breaker, retry, and rate limiter settings in your application YAML.
  • Annotate the AI service with @CircuitBreaker and @Retry so failures trigger a graceful fallback.
  • Apply a @RateLimiter to the invoice upload endpoint and verify the circuit breaker state via Actuator.
Configure Resilience4j in application.yml

Resilience4j reads its configuration from your Spring Boot YAML file. Each pattern (circuit breaker, retry, rate limiter) gets a named instance that you reference later via annotations.

  • Open core-service/src/main/resources/application.yml.
  • Add the following Resilience4j configuration block at the end of the file:
# Resilience4j configuration
resilience4j:
  circuitbreaker:
    instances:
      aiService:
        slidingWindowSize: 10
        failureRateThreshold: 50
        waitDurationInOpenState: 30s
        permittedNumberOfCallsInHalfOpenState: 3
  retry:
    instances:
      aiService:
        maxAttempts: 3
        waitDuration: 2s
  ratelimiter:
    instances:
      uploadEndpoint:
        limitForPeriod: 10
        limitRefreshPeriod: 1s
        timeoutDuration: 0

# Actuator endpoint exposure
management:
  endpoints:
    web:
      exposure:
        include: health,circuitbreakers,metrics

What does this configuration do?

The aiService circuit breaker watches a sliding window of 10 calls. If 50% or more fail, it opens the circuit for 30 seconds. During that time, all calls skip the LLM and hit the fallback immediately. After 30 seconds, it enters half-open state and allows 3 test calls through.

The retry instance attempts each AI call up to 3 times with a 2-second pause between attempts. The rate limiter allows 10 requests per second on the upload endpoint. A timeoutDuration of 0 means requests that exceed the limit are rejected immediately rather than waiting.

The management.endpoints block exposes Actuator endpoints so you can observe circuit breaker states at runtime.

  • Save application.yml.

YAML indentation errors?

YAML is whitespace-sensitive. Make sure resilience4j and management are top-level keys (no leading spaces). Each nested level adds exactly 2 spaces.

If your application fails to start, check that you have not duplicated a top-level key that already exists in the file (like management). Merge under the existing key instead.

Help me debug my Resilience4j YAML configuration

Why both retry AND circuit breaker?

Retry handles transient failures. If Ollama is briefly busy, a second attempt 2 seconds later often succeeds. The circuit breaker handles sustained failures. If Ollama has crashed entirely, retrying 3 times just wastes 6 seconds per request.

With both applied, Resilience4j retries first. If all 3 attempts fail, that counts as one failure against the circuit breaker window. Once 5 out of 10 calls fail (50% threshold), the circuit opens and skips retries entirely.

Add @CircuitBreaker and @Retry to the AI service

The annotations connect your YAML configuration to the actual method that calls the LLM. When the circuit opens, Resilience4j routes calls to a fallback method that returns safe defaults.

  • Open core-service/src/main/java/com/nextwork/expense/service/ExpenseAnalysisService.java.
  • Add these imports at the top of the file:
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import java.math.BigDecimal;
import java.time.LocalDate;
  • Find your existing analyzeExpense method and add the two annotations above the method signature:
@CircuitBreaker(name = "aiService", fallbackMethod = "analyzeExpenseFallback")
@Retry(name = "aiService")
public ExpenseAnalysisResult analyzeExpense(String ocrText) {
    // ... your existing LLM call logic stays unchanged
}
  • Add the fallback method below the analyzeExpense method. This runs when the circuit is open or all retries are exhausted:
// Fallback when Ollama is unavailable - returns safe defaults for manual review
private ExpenseAnalysisResult analyzeExpenseFallback(String ocrText, Exception e) {
    return new ExpenseAnalysisResult(
        "Unknown",
        BigDecimal.ZERO,
        "Manual Review Required",
        LocalDate.now().toString()
    );
}

What does the fallback method do?

The fallback must have the same parameters as the original method plus an Exception parameter. Resilience4j matches methods by this signature.

Instead of crashing, it returns a placeholder result flagged as Manual Review Required. The invoice still gets saved. A human reviewer (or a background retry job) can reclassify it later when the LLM recovers.

  • Save ExpenseAnalysisService.java.

Compilation error on the fallback?

The fallback method signature must exactly match. It needs the same first parameter (String ocrText) plus Exception e as the last parameter. The return type must also match (ExpenseAnalysisResult).

Make sure the fallbackMethod string in the annotation matches the method name exactly, including case.

Help me fix my Resilience4j fallback method signature

Apply @RateLimiter to the upload endpoint and verify via Actuator

The rate limiter on the upload endpoint prevents a single user from flooding the system with invoice processing requests. This protects both the OCR pipeline and the LLM from overload.

  • Open core-service/src/main/java/com/nextwork/expense/controller/InvoiceController.java.
  • Add this import at the top of the file:
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import java.util.Map;
  • Find the POST /upload method and add the @RateLimiter annotation above it:
@RateLimiter(name = "uploadEndpoint", fallbackMethod = "uploadRateLimitFallback")
@PostMapping("/upload")
public ResponseEntity<?> uploadInvoice(@RequestParam("image") MultipartFile image) {
    // ... your existing upload logic stays unchanged
}
  • Add the rate limit fallback method below the upload method:
// Returns 429 Too Many Requests when the rate limit is exceeded
public ResponseEntity<?> uploadRateLimitFallback(MultipartFile image, Exception e) {
    return ResponseEntity.status(429)
        .body(Map.of("error", "Rate limit exceeded. Please try again later."));
}

What does this rate limiter do?

The uploadEndpoint rate limiter allows 10 requests per second. Request number 11 in the same second hits the fallback immediately (timeoutDuration: 0 means no queuing).

The fallback returns HTTP 429 (Too Many Requests), the standard status code for rate limiting. Clients can retry after 1 second when the limiter refreshes.

  • Save InvoiceController.java.
  • Restart your Spring Boot application.
  • Verify the circuit breaker is registered by running this in your terminal:
curl http://localhost:8080/actuator/circuitbreakers

You should see a JSON response listing the aiService circuit breaker with its current state (CLOSED means healthy, all calls passing through).

Actuator endpoint returning 404?

Check that management.endpoints.web.exposure.include in your YAML includes circuitbreakers. The default Spring Boot Actuator only exposes health.

Also confirm that spring-boot-starter-actuator is in your pom.xml dependencies. Without it, no /actuator endpoints exist.

Help me expose Resilience4j Actuator endpoints

Why layered rate limiting matters in interviews

You now have method-level rate limiting on the upload endpoint via Resilience4j. In Step 8, you will add a second layer at the API Gateway using Redis Token Bucket. These serve different purposes.

The gateway rate limiter protects the entire system from abuse (coarse-grained, per-user global). The Resilience4j rate limiter protects specific expensive operations like AI calls (fine-grained, per-endpoint). Explaining this layered strategy in an interview demonstrates architectural maturity.

✔️ Awesome, I've got everything!

Great. Double check you have saved both ExpenseAnalysisService.java and InvoiceController.java.

ⓧ I'd like to double check the full code

Here is the Resilience4j section of your application.yml:

# Resilience4j configuration
resilience4j:
  circuitbreaker:
    instances:
      aiService:
        slidingWindowSize: 10
        failureRateThreshold: 50
        waitDurationInOpenState: 30s
        permittedNumberOfCallsInHalfOpenState: 3
  retry:
    instances:
      aiService:
        maxAttempts: 3
        waitDuration: 2s
  ratelimiter:
    instances:
      uploadEndpoint:
        limitForPeriod: 10
        limitRefreshPeriod: 1s
        timeoutDuration: 0

# Actuator endpoint exposure
management:
  endpoints:
    web:
      exposure:
        include: health,circuitbreakers,metrics

Here are the annotations and fallback in ExpenseAnalysisService.java:

import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import java.math.BigDecimal;
import java.time.LocalDate;

// ... existing class code ...

@CircuitBreaker(name = "aiService", fallbackMethod = "analyzeExpenseFallback")
@Retry(name = "aiService")
public ExpenseAnalysisResult analyzeExpense(String ocrText) {
    // ... your existing LLM call logic
}

// Fallback when Ollama is unavailable - returns safe defaults for manual review
private ExpenseAnalysisResult analyzeExpenseFallback(String ocrText, Exception e) {
    return new ExpenseAnalysisResult(
        "Unknown",
        BigDecimal.ZERO,
        "Manual Review Required",
        LocalDate.now().toString()
    );
}

Here is the rate limiter addition in InvoiceController.java:

import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import java.util.Map;

// ... existing class code ...

@RateLimiter(name = "uploadEndpoint", fallbackMethod = "uploadRateLimitFallback")
@PostMapping("/upload")
public ResponseEntity<?> uploadInvoice(@RequestParam("image") MultipartFile image) {
    // ... your existing upload logic
}

// Returns 429 Too Many Requests when the rate limit is exceeded
public ResponseEntity<?> uploadRateLimitFallback(MultipartFile image, Exception e) {
    return ResponseEntity.status(429)
        .body(Map.of("error", "Rate limit exceeded. Please try again later."));
}

Your AI service is now protected against cascading failures. Next up, you will build the API Gateway module with Redis-backed Token Bucket rate limiting for system-wide protection.

Build the API Gateway with Redis Rate Limiting

Your core service now has resilience built in at the method level with circuit breakers and rate limiters.

In production, you also need a gateway sitting in front of your services to enforce system-wide rate limiting before requests even reach the application code. In this step, you will configure Spring Cloud Gateway to proxy all requests to your core service, using a Redis-backed Token Bucket algorithm for per-user rate limiting.

In this step, get ready to:
  • Create the Gateway application entry point and configure route definitions.
  • Set up Redis-backed Token Bucket rate limiting on all proxied requests.
  • Implement a custom KeyResolver that extracts the user ID from JWT tokens for per-user limiting.
Create the Gateway application class

Spring Cloud Gateway runs on a reactive stack (Netty) and cannot share the same Spring Boot application as your servlet-based core service. Your gateway module already has the required dependencies, so you just need to create the main application class.

  • In the gateway module, create the package directory src/main/java/com/nextwork/gateway if it does not already exist.
  • Create a new file called GatewayApplication.java in that package and paste the following code:
package com.nextwork.gateway;

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

@SpringBootApplication
public class GatewayApplication {

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

What does this code do?

This is a standard Spring Boot entry point. The @SpringBootApplication annotation triggers auto-configuration, which detects the Gateway dependencies on the classpath and starts a Netty server instead of Tomcat.

  • Save GatewayApplication.java.
Configure routes and Redis rate limiting

The gateway needs to know where to forward requests and how aggressively to rate limit them. Spring Cloud Gateway uses a built-in RequestRateLimiter filter that implements the Token Bucket algorithm via Redis Lua scripts.

What is the Token Bucket algorithm?

Imagine a bucket that holds tokens. Each request costs one token. The bucket refills at a steady rate (the replenish rate). If the bucket is empty, the request is rejected with a 429 status.

The burst capacity is the maximum number of tokens the bucket can hold. This allows short bursts of traffic above the steady rate, as long as the bucket has accumulated enough tokens.

  • Create the file gateway/src/main/resources/application.yml and paste the following configuration:
server:
  port: 8090

spring:
  data:
    redis:
      host: localhost
      port: 6379
  cloud:
    gateway:
      routes:
        - id: core-service-route
          uri: http://localhost:8080
          predicates:
            - Path=/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10
                redis-rate-limiter.burstCapacity: 20
                redis-rate-limiter.requestedTokens: 1
                key-resolver: "#{@jwtKeyResolver}"

What does this configuration do?

  • server.port: 8090 runs the gateway on a different port than the core service (8080).
  • spring.data.redis connects to the same Redis instance your Docker Compose stack provides.
  • The route with Path=/** forwards all incoming requests to the core service at http://localhost:8080.
  • replenishRate: 10 means the bucket gains 10 tokens per second.
  • burstCapacity: 20 means the bucket can hold a maximum of 20 tokens, allowing short bursts.
  • key-resolver: "#{@jwtKeyResolver}" references a Spring bean that determines which user each request belongs to.
  • Save application.yml.

Why both gateway rate limiting AND Resilience4j rate limiting?

These serve different purposes in a real architecture. The gateway rate limiter protects the entire system from abuse at the edge (coarse-grained, per-user). The Resilience4j rate limiter on specific service methods protects expensive operations like AI calls (fine-grained, per-endpoint).

In an interview, explaining this layered strategy shows you understand defense in depth.

Implement the JWT KeyResolver for per-user rate limiting

The KeyResolver tells the rate limiter how to identify each user. Without it, all requests share a single bucket and one heavy user could exhaust the limit for everyone.

  • Create the package directory gateway/src/main/java/com/nextwork/gateway/config.
  • Create a new file called GatewayConfig.java in that package and paste the following code:
package com.nextwork.gateway.config;

import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import reactor.core.publisher.Mono;

import java.util.Base64;

@Configuration
public class GatewayConfig {

    @Bean
    public KeyResolver jwtKeyResolver() {
        return exchange -> {
            // Extract the Authorization header from the incoming request
            String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization");

            if (authHeader != null && authHeader.startsWith("Bearer ")) {
                // Strip the "Bearer " prefix to get the raw JWT token
                String token = authHeader.substring(7);
                String[] parts = token.split("\\.");

                // Decode the payload (second segment) from Base64
                String payload = new String(Base64.getUrlDecoder().decode(parts[1]));

                // Extract the "sub" claim (user ID) from the JSON payload
                int subIndex = payload.indexOf("\"sub\"");
                if (subIndex != -1) {
                    int valueStart = payload.indexOf("\"", subIndex + 5) + 1;
                    int valueEnd = payload.indexOf("\"", valueStart);
                    String userId = payload.substring(valueStart, valueEnd);
                    return Mono.just(userId);
                }
            }

            // Fall back to remote IP if no JWT present
            return Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
        };
    }
}

What does this code do?

  • The KeyResolver interface returns a Mono<String> that identifies who is making the request.
  • It reads the Authorization header, strips the Bearer prefix, and decodes the JWT payload (the middle segment) from Base64.
  • It extracts the sub claim (the user ID your core service stores in the token) so each user gets their own rate limit bucket.
  • If no JWT is present (unauthenticated request), it falls back to the client's IP address so anonymous users are still rate limited.
  • Save GatewayConfig.java.
  • Make sure your Docker Compose stack is running (Redis on port 6379 and RabbitMQ).
  • Run the GatewayApplication class from IntelliJ by clicking the green play icon next to the main method.

You should see Netty starting on port 8090 in the console output. The log line will say something like Netty started on port 8090.

  • Test the gateway is proxying correctly by sending a request through port 8090 instead of directly to 8080. Run this in your terminal:
curl -i http://localhost:8090/api/analytics/summary -H "Authorization: Bearer <your-jwt-token>"

You should get the same response as hitting port 8080 directly. The -i flag shows response headers. Look for X-RateLimit-Remaining in the headers, which shows how many tokens remain in your bucket.

Gateway not starting or returning 429 immediately?

  • If the gateway fails to start with a Redis connection error, confirm your Docker Compose stack is running and Redis is accessible on port 6379.
  • If you get a 429 on your very first request, check that the jwtKeyResolver bean name in your configuration matches the bean method name exactly.
  • If you see a 500 error with a NullPointerException in the KeyResolver, make sure you are sending a valid JWT in the Authorization header, or test without the header to trigger the IP fallback path.

Help me debug my Spring Cloud Gateway rate limiting setup

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files in the gateway module.

ⓧ I'd like to double check the full code

Here is the complete code for all three gateway files.

package com.nextwork.gateway;

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

@SpringBootApplication
public class GatewayApplication {

    public static void main(String[] args) {
        SpringApplication.run(GatewayApplication.class, args);
    }
}
server:
  port: 8090

spring:
  data:
    redis:
      host: localhost
      port: 6379
  cloud:
    gateway:
      routes:
        - id: core-service-route
          uri: http://localhost:8080
          predicates:
            - Path=/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10
                redis-rate-limiter.burstCapacity: 20
                redis-rate-limiter.requestedTokens: 1
                key-resolver: "#{@jwtKeyResolver}"
package com.nextwork.gateway.config;

import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import reactor.core.publisher.Mono;

import java.util.Base64;

@Configuration
public class GatewayConfig {

    @Bean
    public KeyResolver jwtKeyResolver() {
        return exchange -> {
            // Extract the Authorization header from the incoming request
            String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization");

            if (authHeader != null && authHeader.startsWith("Bearer ")) {
                // Strip the "Bearer " prefix to get the raw JWT token
                String token = authHeader.substring(7);
                String[] parts = token.split("\\.");

                // Decode the payload (second segment) from Base64
                String payload = new String(Base64.getUrlDecoder().decode(parts[1]));

                // Extract the "sub" claim (user ID) from the JSON payload
                int subIndex = payload.indexOf("\"sub\"");
                if (subIndex != -1) {
                    int valueStart = payload.indexOf("\"", subIndex + 5) + 1;
                    int valueEnd = payload.indexOf("\"", valueStart);
                    String userId = payload.substring(valueStart, valueEnd);
                    return Mono.just(userId);
                }
            }

            // Fall back to remote IP if no JWT present
            return Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
        };
    }
}

Your gateway is now sitting in front of the core service, enforcing per-user rate limits via Redis before requests even reach your application logic. Next up, you will integrate Stripe payment processing so the system can initiate payments based on extracted invoice amounts.

Integrate Stripe Payment Processing

Your expense analyzer can process invoices and categorize spending, but a real fintech application needs to handle payments too.

In this step, you will integrate Stripe payment processing so the system can initiate payments for processed invoices. Stripe's test mode lets you build the full payment flow without any real charges.

In this step, get ready to:
  • Configure the Stripe test secret key and create the Payment entity.
  • Build a PaymentService that creates a Stripe PaymentIntent for a given invoice amount.
  • Create a REST endpoint that initiates payments and stores payment records.
Configure Stripe and create the Payment entity

Stripe uses a secret key to authenticate your server-side API calls. You already have the SDK dependency and a test account ready, so the next step is wiring the key into your Spring Boot configuration.

  • Open core-service/src/main/resources/application.yml and add the following Stripe configuration at the bottom of the file:
# Stripe payment processing configuration
stripe:
  secret-key: sk_test_xxxx

What does this do?

The stripe.secret-key property stores your Stripe test API key. Spring Boot injects this value into any bean that uses @Value("${stripe.secret-key}").

Replace sk_test_xxxx with your actual Stripe test secret key from your Stripe dashboard. Test keys start with sk_test_ and never trigger real charges.

  • Save application.yml.

Now you need a JPA entity to store payment records in the database. Each payment links back to an invoice and tracks the Stripe PaymentIntent ID so you can reconcile later.

  • Create a new file Payment.java inside core-service/src/main/java/com/nextwork/expense/entity/ and add the following code:
package com.nextwork.expense.entity;

import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;

@Entity
@Table(name = "payments")
public class Payment {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    // Links this payment to the processed invoice
    @Column(name = "invoice_id")
    private UUID invoiceId;

    // The user who initiated the payment
    @Column(name = "user_id")
    private UUID userId;

    // Stripe's unique identifier for the PaymentIntent
    @Column(name = "stripe_payment_intent_id")
    private String stripePaymentIntentId;

    // Payment amount in dollars (converted to cents when sent to Stripe)
    @Column(precision = 10, scale = 2)
    private BigDecimal amount;

    // Payment status: PENDING, PAID, or PAYMENT_FAILED
    private String status;

    @CreationTimestamp
    private LocalDateTime createdAt;

    // Getters and setters
    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }

    public UUID getInvoiceId() { return invoiceId; }
    public void setInvoiceId(UUID invoiceId) { this.invoiceId = invoiceId; }

    public UUID getUserId() { return userId; }
    public void setUserId(UUID userId) { this.userId = userId; }

    public String getStripePaymentIntentId() { return stripePaymentIntentId; }
    public void setStripePaymentIntentId(String stripePaymentIntentId) { this.stripePaymentIntentId = stripePaymentIntentId; }

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

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

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

What does this code do?

The Payment entity maps to a payments table in PostgreSQL. It stores the relationship between your local invoice records and Stripe's PaymentIntent.

The stripePaymentIntentId field is critical for reconciliation. When Stripe sends webhook events about a payment, this ID is how you match it back to your local record.

  • Save Payment.java.

Next, create the repository, request DTO, and response DTO that the payment flow will use.

  • Create a new file PaymentRepository.java inside core-service/src/main/java/com/nextwork/expense/repository/ with the following code:
package com.nextwork.expense.repository;

import com.nextwork.expense.entity.Payment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;

// Spring Data JPA generates the query implementation automatically
public interface PaymentRepository extends JpaRepository<Payment, UUID> {

    // Finds a payment by its Stripe PaymentIntent ID (used for webhook reconciliation)
    Optional<Payment> findByStripePaymentIntentId(String stripePaymentIntentId);
}
  • Create a new file PaymentResponse.java inside core-service/src/main/java/com/nextwork/expense/dto/ with the following code:
package com.nextwork.expense.dto;

import java.util.UUID;

// Returns the payment ID and Stripe client secret to the caller
public record PaymentResponse(UUID paymentId, String clientSecret) {}
  • Create a new file InitiatePaymentRequest.java inside the same dto folder with the following code:
package com.nextwork.expense.dto;

import java.util.UUID;

// Request body for the payment initiation endpoint
public record InitiatePaymentRequest(UUID invoiceId) {}

What are Java records?

Java records are compact data carriers introduced in Java 16. They automatically generate the constructor, getters, equals(), hashCode(), and toString() methods from the declared fields.

Records are perfect for DTOs because they are immutable and concise. You access the fields using method-style accessors like request.invoiceId() instead of request.getInvoiceId().

  • Save all three files: PaymentRepository.java, PaymentResponse.java, and InitiatePaymentRequest.java.
  • Verify the project compiles by checking for no red squiggles in IntelliJ IDEA.

Seeing compilation errors?

Make sure the package declarations match your folder structure exactly. The entity should be in com.nextwork.expense.entity, the repository in com.nextwork.expense.repository, and the DTOs in com.nextwork.expense.dto.

If IntelliJ shows unresolved imports, try clicking File then Invalidate Caches / Restart to refresh the project index.

Help me fix compilation errors in my Payment entity

Build the PaymentService with Stripe PaymentIntent creation

Stripe processes payments through a two-step flow. First, your server creates a PaymentIntent (declaring how much to charge and in what currency). Then Stripe returns a client secret that a frontend would use to confirm the payment.

  • Create a new file PaymentService.java inside core-service/src/main/java/com/nextwork/expense/service/ and add the following code:
package com.nextwork.expense.service;

import com.nextwork.expense.dto.PaymentResponse;
import com.nextwork.expense.entity.Expense;
import com.nextwork.expense.entity.Invoice;
import com.nextwork.expense.entity.Payment;
import com.nextwork.expense.repository.ExpenseRepository;
import com.nextwork.expense.repository.InvoiceRepository;
import com.nextwork.expense.repository.PaymentRepository;
import com.stripe.Stripe;
import com.stripe.model.PaymentIntent;
import com.stripe.param.PaymentIntentCreateParams;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.util.UUID;

@Service
public class PaymentService {

    // Injected from application.yml stripe.secret-key property
    @Value("${stripe.secret-key}")
    private String stripeSecretKey;

    private final InvoiceRepository invoiceRepository;
    private final ExpenseRepository expenseRepository;
    private final PaymentRepository paymentRepository;

    public PaymentService(InvoiceRepository invoiceRepository,
                          ExpenseRepository expenseRepository,
                          PaymentRepository paymentRepository) {
        this.invoiceRepository = invoiceRepository;
        this.expenseRepository = expenseRepository;
        this.paymentRepository = paymentRepository;
    }

    public PaymentResponse initiatePayment(UUID invoiceId, UUID userId) throws Exception {
        // Look up the invoice to verify it exists
        Invoice invoice = invoiceRepository.findById(invoiceId)
                .orElseThrow(() -> new RuntimeException("Invoice not found"));

        // Get the expense amount from the associated expense record
        Expense expense = expenseRepository.findByInvoiceId(invoiceId)
                .orElseThrow(() -> new RuntimeException("Expense not found for invoice"));

        // Set the Stripe API key for this request
        Stripe.apiKey = stripeSecretKey;

        // Build PaymentIntent params - amount must be in cents (multiply dollars by 100)
        PaymentIntentCreateParams params = PaymentIntentCreateParams.builder()
                .setAmount(expense.getAmount().multiply(BigDecimal.valueOf(100)).longValue())
                .setCurrency("usd")
                .putMetadata("invoiceId", invoiceId.toString())
                .build();

        // Create the PaymentIntent via Stripe API
        PaymentIntent intent = PaymentIntent.create(params);

        // Store the payment record locally with PENDING status
        Payment payment = new Payment();
        payment.setInvoiceId(invoiceId);
        payment.setUserId(userId);
        payment.setStripePaymentIntentId(intent.getId());
        payment.setAmount(expense.getAmount());
        payment.setStatus("PENDING");
        paymentRepository.save(payment);

        // Return the payment ID and client secret for the caller
        return new PaymentResponse(payment.getId(), intent.getClientSecret());
    }
}

What does this code do?

  • Looks up the invoice and its associated expense to get the payment amount.
  • Converts the dollar amount to cents (Stripe requires amounts in the smallest currency unit).
  • Adds the invoiceId as metadata on the PaymentIntent so you can trace it back in the Stripe dashboard.
  • Creates the PaymentIntent via Stripe's API, which returns a unique ID and a client secret.
  • Saves a local Payment record with status PENDING so you can track the payment lifecycle.

Why set Stripe.apiKey inside the method?

Setting Stripe.apiKey is a global static assignment on the Stripe class. In a production system, you would set this once at startup (e.g., in a @PostConstruct method or configuration bean). For this project, setting it in the service method keeps the flow clear and self-contained.

  • Save PaymentService.java.
  • Verify that IntelliJ resolves all Stripe imports (com.stripe.Stripe, com.stripe.model.PaymentIntent, com.stripe.param.PaymentIntentCreateParams) without errors.

Stripe imports not resolving?

Confirm that your core-service/pom.xml contains the Stripe dependency com.stripe:stripe-java:32.0.0. If it is present but still unresolved, right-click your pom.xml and select Maven then Reload Project to force IntelliJ to re-download dependencies.

Help me fix Stripe import resolution issues in IntelliJ

Create the payment initiation endpoint

The last piece is a REST controller that exposes the payment flow to API consumers. It extracts the authenticated user from the JWT token and delegates to the PaymentService.

  • Create a new file PaymentController.java inside core-service/src/main/java/com/nextwork/expense/controller/ and add the following code:
package com.nextwork.expense.controller;

import com.nextwork.expense.dto.InitiatePaymentRequest;
import com.nextwork.expense.dto.PaymentResponse;
import com.nextwork.expense.service.PaymentService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

import java.util.UUID;

@RestController
@RequestMapping("/api/payments")
public class PaymentController {

    private final PaymentService paymentService;

    public PaymentController(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @PostMapping("/initiate")
    public ResponseEntity<PaymentResponse> initiatePayment(
            @RequestBody InitiatePaymentRequest request) throws Exception {
        // Extract the user ID from the JWT token stored in the security context
        String userId = SecurityContextHolder.getContext()
                .getAuthentication().getName();

        // Delegate to the service to create the Stripe PaymentIntent
        PaymentResponse response = paymentService.initiatePayment(
                request.invoiceId(), UUID.fromString(userId));

        return ResponseEntity.ok(response);
    }
}

What does this code do?

  • The @RequestMapping("/api/payments") sets the base path for all endpoints in this controller.
  • SecurityContextHolder.getContext().getAuthentication().getName() retrieves the user ID that your JWT filter stored during token validation.
  • The endpoint returns a PaymentResponse containing the local payment ID and the Stripe client secret that a frontend would use to complete the payment.
  • Save PaymentController.java.
  • Restart your core-service application.
  • You should see the application start without errors and the payments table should be auto-created by Hibernate in your PostgreSQL database.

Application fails to start?

If you see a Could not resolve placeholder 'stripe.secret-key' error, check that your application.yml has the stripe.secret-key property with correct YAML indentation (two spaces under stripe:).

If you see a bean creation error for PaymentService, make sure ExpenseRepository has a findByInvoiceId method defined. Spring Data JPA requires method signatures to match entity field names exactly.

Help me debug my PaymentService startup error

To test the endpoint, send a POST request with a valid JWT token and an invoice ID that has a processed expense record.

  • Test the payment endpoint by running this curl command (replace the token and invoice ID with your actual values):
curl -X POST http://localhost:8080/api/payments/initiate \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-jwt-token-here" \
  -d '{"invoiceId": "your-invoice-id-here"}'

What should I see?

A successful response returns a JSON body with a paymentId (UUID) and a clientSecret (a string starting with pi_). The client secret is what a frontend would pass to Stripe.js to confirm the payment.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for each file created in this step.

package com.nextwork.expense.entity;

import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;

@Entity
@Table(name = "payments")
public class Payment {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(name = "invoice_id")
    private UUID invoiceId;

    @Column(name = "user_id")
    private UUID userId;

    @Column(name = "stripe_payment_intent_id")
    private String stripePaymentIntentId;

    @Column(precision = 10, scale = 2)
    private BigDecimal amount;

    private String status;

    @CreationTimestamp
    private LocalDateTime createdAt;

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

    public UUID getInvoiceId() { return invoiceId; }
    public void setInvoiceId(UUID invoiceId) { this.invoiceId = invoiceId; }

    public UUID getUserId() { return userId; }
    public void setUserId(UUID userId) { this.userId = userId; }

    public String getStripePaymentIntentId() { return stripePaymentIntentId; }
    public void setStripePaymentIntentId(String stripePaymentIntentId) { this.stripePaymentIntentId = stripePaymentIntentId; }

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

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

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
package com.nextwork.expense.repository;

import com.nextwork.expense.entity.Payment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;

public interface PaymentRepository extends JpaRepository<Payment, UUID> {
    Optional<Payment> findByStripePaymentIntentId(String stripePaymentIntentId);
}
package com.nextwork.expense.dto;

import java.util.UUID;

public record PaymentResponse(UUID paymentId, String clientSecret) {}
package com.nextwork.expense.dto;

import java.util.UUID;

public record InitiatePaymentRequest(UUID invoiceId) {}
package com.nextwork.expense.service;

import com.nextwork.expense.dto.PaymentResponse;
import com.nextwork.expense.entity.Expense;
import com.nextwork.expense.entity.Invoice;
import com.nextwork.expense.entity.Payment;
import com.nextwork.expense.repository.ExpenseRepository;
import com.nextwork.expense.repository.InvoiceRepository;
import com.nextwork.expense.repository.PaymentRepository;
import com.stripe.Stripe;
import com.stripe.model.PaymentIntent;
import com.stripe.param.PaymentIntentCreateParams;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.util.UUID;

@Service
public class PaymentService {

    @Value("${stripe.secret-key}")
    private String stripeSecretKey;

    private final InvoiceRepository invoiceRepository;
    private final ExpenseRepository expenseRepository;
    private final PaymentRepository paymentRepository;

    public PaymentService(InvoiceRepository invoiceRepository,
                          ExpenseRepository expenseRepository,
                          PaymentRepository paymentRepository) {
        this.invoiceRepository = invoiceRepository;
        this.expenseRepository = expenseRepository;
        this.paymentRepository = paymentRepository;
    }

    public PaymentResponse initiatePayment(UUID invoiceId, UUID userId) throws Exception {
        Invoice invoice = invoiceRepository.findById(invoiceId)
                .orElseThrow(() -> new RuntimeException("Invoice not found"));

        Expense expense = expenseRepository.findByInvoiceId(invoiceId)
                .orElseThrow(() -> new RuntimeException("Expense not found for invoice"));

        Stripe.apiKey = stripeSecretKey;

        PaymentIntentCreateParams params = PaymentIntentCreateParams.builder()
                .setAmount(expense.getAmount().multiply(BigDecimal.valueOf(100)).longValue())
                .setCurrency("usd")
                .putMetadata("invoiceId", invoiceId.toString())
                .build();

        PaymentIntent intent = PaymentIntent.create(params);

        Payment payment = new Payment();
        payment.setInvoiceId(invoiceId);
        payment.setUserId(userId);
        payment.setStripePaymentIntentId(intent.getId());
        payment.setAmount(expense.getAmount());
        payment.setStatus("PENDING");
        paymentRepository.save(payment);

        return new PaymentResponse(payment.getId(), intent.getClientSecret());
    }
}
package com.nextwork.expense.controller;

import com.nextwork.expense.dto.InitiatePaymentRequest;
import com.nextwork.expense.dto.PaymentResponse;
import com.nextwork.expense.service.PaymentService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

import java.util.UUID;

@RestController
@RequestMapping("/api/payments")
public class PaymentController {

    private final PaymentService paymentService;

    public PaymentController(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @PostMapping("/initiate")
    public ResponseEntity<PaymentResponse> initiatePayment(
            @RequestBody InitiatePaymentRequest request) throws Exception {
        String userId = SecurityContextHolder.getContext()
                .getAuthentication().getName();

        PaymentResponse response = paymentService.initiatePayment(
                request.invoiceId(), UUID.fromString(userId));

        return ResponseEntity.ok(response);
    }
}

Your application can now initiate real Stripe payments for processed invoices. Next up, you will build spending analytics APIs that aggregate expense data by category and time period.

Build the Spending Analytics API

Your system can now process invoices, categorize expenses with AI, handle payments, and protect itself with circuit breakers and rate limiting. The next question is: what can users actually learn from all that data?

In this step, you will build the spending analytics API that aggregates expense data into actionable insights. Users will be able to see category breakdowns, monthly trends, and a summary with month-over-month change percentages.

In this step, get ready to:
  • Implement AnalyticsService with category and monthly breakdown methods.
  • Build the summary computation with month-over-month change logic.
  • Complete the AnalyticsController and test all three endpoints.
Implement the category and monthly breakdown service methods

Your ExpenseRepository already has JPQL queries that aggregate spending by category and by month. Now you need to build the service layer that transforms those raw query results into sorted, cached DTOs.

  • Open AnalyticsService.java in core-service/src/main/java/com/nextwork/expense/service/.
  • Replace the contents of the file with the following implementation:
package com.nextwork.expense.service;

import com.nextwork.expense.dto.CategoryTotalDTO;
import com.nextwork.expense.dto.MonthlyTotalDTO;
import com.nextwork.expense.dto.SummaryDTO;
import com.nextwork.expense.repository.ExpenseRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;

@Service
public class AnalyticsService {

    private final ExpenseRepository expenseRepository;

    public AnalyticsService(ExpenseRepository expenseRepository) {
        this.expenseRepository = expenseRepository;
    }

    // Returns spending totals grouped by category, sorted highest to lowest
    @Cacheable(value = "analytics", key = "#userId + ':categories'")
    public List<CategoryTotalDTO> getCategoryBreakdown(UUID userId) {
        List<Object[]> results = expenseRepository.findCategoryTotalsByUserId(userId);
        return results.stream()
                .map(row -> new CategoryTotalDTO((String) row[0], (BigDecimal) row[1]))
                .sorted(Comparator.comparing(CategoryTotalDTO::total).reversed())
                .toList();
    }

    // Returns spending totals grouped by year and month, sorted chronologically
    @Cacheable(value = "analytics", key = "#userId + ':monthly'")
    public List<MonthlyTotalDTO> getMonthlyBreakdown(UUID userId) {
        List<Object[]> results = expenseRepository.findMonthlyTotalsByUserId(userId);
        return results.stream()
                .map(row -> new MonthlyTotalDTO((Integer) row[0], (Integer) row[1], (BigDecimal) row[2]))
                .sorted(Comparator.comparing(MonthlyTotalDTO::year)
                        .thenComparing(MonthlyTotalDTO::month))
                .toList();
    }
}

What does this code do?

Each method queries the repository for raw aggregated data (Object arrays from JPQL GROUP BY queries), then maps the results into typed DTOs.

The @Cacheable annotation means the first call hits PostgreSQL, but subsequent calls return instantly from Redis. The cache key includes the user ID so each user gets their own cached results.

  • Save AnalyticsService.java.

Seeing a compilation error on the DTO constructors?

Make sure your CategoryTotalDTO and MonthlyTotalDTO records have the correct field names and types. CategoryTotalDTO should have String category and BigDecimal total. MonthlyTotalDTO should have Integer year, Integer month, and BigDecimal total.

Help me fix my AnalyticsService compilation errors

Add the summary computation with month-over-month change

The summary endpoint is the most valuable for users. It computes three metrics: total spending, top category, and a percentage showing how spending changed from the previous month to the current one.

  • In AnalyticsService.java, add the following methods below the getMonthlyBreakdown method:
    // Computes aggregate summary: total spending, top category, month-over-month change
    public SummaryDTO getSummary(UUID userId) {
        List<CategoryTotalDTO> categories = getCategoryBreakdown(userId);
        List<MonthlyTotalDTO> monthly = getMonthlyBreakdown(userId);

        // Total spending is the sum of all category totals
        BigDecimal totalSpending = categories.stream()
                .map(CategoryTotalDTO::total)
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        // Top category is the first entry (already sorted descending by total)
        String topCategory = categories.isEmpty() ? "None" : categories.get(0).category();

        // Calculate percentage change between the two most recent months
        BigDecimal monthOverMonthChange = calculateMonthOverMonthChange(monthly);

        return new SummaryDTO(totalSpending, topCategory, monthOverMonthChange);
    }

    // Computes (current - previous) / previous * 100 as percentage change
    private BigDecimal calculateMonthOverMonthChange(List<MonthlyTotalDTO> monthly) {
        if (monthly.size() < 2) {
            return BigDecimal.ZERO;
        }
        // Monthly list is sorted chronologically, so last entry is the most recent
        BigDecimal currentMonth = monthly.get(monthly.size() - 1).total();
        BigDecimal previousMonth = monthly.get(monthly.size() - 2).total();

        if (previousMonth.compareTo(BigDecimal.ZERO) == 0) {
            return BigDecimal.ZERO;
        }

        return currentMonth.subtract(previousMonth)
                .divide(previousMonth, 4, RoundingMode.HALF_UP)
                .multiply(BigDecimal.valueOf(100))
                .setScale(1, RoundingMode.HALF_UP);
    }

    // Evicts cached category breakdown when new expenses are added
    @CacheEvict(value = "analytics", key = "#userId + ':categories'")
    public void evictCategoriesCache(UUID userId) {
    }

    // Evicts cached monthly breakdown when new expenses are added
    @CacheEvict(value = "analytics", key = "#userId + ':monthly'")
    public void evictMonthlyCache(UUID userId) {
    }

How does the month-over-month change work?

The formula is (currentMonth - previousMonth) / previousMonth * 100. If a user spent $300 last month and $345 this month, the result is 15.0% (a 15% increase).

The edge cases matter here. If there is fewer than 2 months of data, or the previous month is zero, the method returns 0 to avoid division errors. RoundingMode.HALF_UP ensures consistent decimal rounding for financial calculations.

Why doesn't getSummary have @Cacheable?

The getSummary method calls getCategoryBreakdown and getMonthlyBreakdown internally. Those methods are already cached in Redis. Adding another cache layer on top would be redundant and harder to invalidate correctly.

  • Save AnalyticsService.java.
  • Verify that IntelliJ shows no red underlines in the file.

Seeing errors on RoundingMode or BigDecimal?

Make sure you have import java.math.BigDecimal; and import java.math.RoundingMode; at the top of the file. IntelliJ can auto-import these with Option+Enter (macOS) or Alt+Enter (Windows) on the red-underlined class name.

Help me fix import errors in my AnalyticsService

Complete the AnalyticsController and test the endpoints

The controller wires each analytics service method to a REST endpoint. Each endpoint extracts the user ID from the JWT token in the security context, so users can only see their own data.

  • Open AnalyticsController.java in core-service/src/main/java/com/nextwork/expense/controller/.
  • Replace the contents with the following full implementation:
package com.nextwork.expense.controller;

import com.nextwork.expense.dto.CategoryTotalDTO;
import com.nextwork.expense.dto.MonthlyTotalDTO;
import com.nextwork.expense.dto.SummaryDTO;
import com.nextwork.expense.service.AnalyticsService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.UUID;

@RestController
@RequestMapping("/api/analytics")
public class AnalyticsController {

    private final AnalyticsService analyticsService;

    public AnalyticsController(AnalyticsService analyticsService) {
        this.analyticsService = analyticsService;
    }

    // GET /api/analytics/categories - returns spending grouped by category
    @GetMapping("/categories")
    public ResponseEntity<List<CategoryTotalDTO>> getCategoryBreakdown() {
        UUID userId = UUID.fromString(
                SecurityContextHolder.getContext().getAuthentication().getName());
        return ResponseEntity.ok(analyticsService.getCategoryBreakdown(userId));
    }

    // GET /api/analytics/monthly - returns spending grouped by year and month
    @GetMapping("/monthly")
    public ResponseEntity<List<MonthlyTotalDTO>> getMonthlyBreakdown() {
        UUID userId = UUID.fromString(
                SecurityContextHolder.getContext().getAuthentication().getName());
        return ResponseEntity.ok(analyticsService.getMonthlyBreakdown(userId));
    }

    // GET /api/analytics/summary - returns total, top category, and month-over-month change
    @GetMapping("/summary")
    public ResponseEntity<SummaryDTO> getSummary() {
        UUID userId = UUID.fromString(
                SecurityContextHolder.getContext().getAuthentication().getName());
        return ResponseEntity.ok(analyticsService.getSummary(userId));
    }
}

What does this code do?

Each endpoint follows the same pattern: extract the authenticated user's ID from the SecurityContextHolder (populated by your JWT filter), pass it to the corresponding service method, and return the result wrapped in a 200 OK response.

This ensures users can only query their own expense data. The JWT token encodes the user ID as the principal name, which is why getAuthentication().getName() gives us the UUID string.

  • Save AnalyticsController.java.
  • Restart your Spring Boot application.

Now test all three endpoints. You need a valid JWT token from your login endpoint first.

  • Get a JWT token by calling your login endpoint (use the credentials you registered earlier).
  • Test the categories endpoint by running this curl command (replace the token with your actual JWT):
curl -H "Authorization: Bearer your-jwt-token-here" http://localhost:8080/api/analytics/categories
  • Test the monthly breakdown endpoint:
curl -H "Authorization: Bearer your-jwt-token-here" http://localhost:8080/api/analytics/monthly
  • Test the summary endpoint:
curl -H "Authorization: Bearer your-jwt-token-here" http://localhost:8080/api/analytics/summary

You should see JSON responses for each endpoint. The categories endpoint returns an array sorted by total descending. The monthly endpoint returns an array sorted chronologically. The summary returns an object with totalSpending, topCategory, and monthOverMonthChange.

How do I verify Redis caching is working?

Call the same endpoint twice. The first request queries PostgreSQL. The second request should return noticeably faster because it is served from Redis.

You can also check Redis directly by running:

docker exec -it redis redis-cli KEYS "analytics*"

You should see keys like analytics::your-user-uuid:categories and analytics::your-user-uuid:monthly in the output.

Getting a 403 Forbidden response?

Make sure you are including the Authorization: Bearer header with a valid, non-expired JWT token. If your token has expired, call your login endpoint again to get a fresh one.

Also check that your Spring Security configuration permits GET requests to /api/analytics/** for authenticated users.

Help me debug my 403 error on the analytics endpoints

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete AnalyticsService.java:

package com.nextwork.expense.service;

import com.nextwork.expense.dto.CategoryTotalDTO;
import com.nextwork.expense.dto.MonthlyTotalDTO;
import com.nextwork.expense.dto.SummaryDTO;
import com.nextwork.expense.repository.ExpenseRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;

@Service
public class AnalyticsService {

    private final ExpenseRepository expenseRepository;

    public AnalyticsService(ExpenseRepository expenseRepository) {
        this.expenseRepository = expenseRepository;
    }

    // Returns spending totals grouped by category, sorted highest to lowest
    @Cacheable(value = "analytics", key = "#userId + ':categories'")
    public List<CategoryTotalDTO> getCategoryBreakdown(UUID userId) {
        List<Object[]> results = expenseRepository.findCategoryTotalsByUserId(userId);
        return results.stream()
                .map(row -> new CategoryTotalDTO((String) row[0], (BigDecimal) row[1]))
                .sorted(Comparator.comparing(CategoryTotalDTO::total).reversed())
                .toList();
    }

    // Returns spending totals grouped by year and month, sorted chronologically
    @Cacheable(value = "analytics", key = "#userId + ':monthly'")
    public List<MonthlyTotalDTO> getMonthlyBreakdown(UUID userId) {
        List<Object[]> results = expenseRepository.findMonthlyTotalsByUserId(userId);
        return results.stream()
                .map(row -> new MonthlyTotalDTO((Integer) row[0], (Integer) row[1], (BigDecimal) row[2]))
                .sorted(Comparator.comparing(MonthlyTotalDTO::year)
                        .thenComparing(MonthlyTotalDTO::month))
                .toList();
    }

    // Computes aggregate summary: total spending, top category, month-over-month change
    public SummaryDTO getSummary(UUID userId) {
        List<CategoryTotalDTO> categories = getCategoryBreakdown(userId);
        List<MonthlyTotalDTO> monthly = getMonthlyBreakdown(userId);

        // Total spending is the sum of all category totals
        BigDecimal totalSpending = categories.stream()
                .map(CategoryTotalDTO::total)
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        // Top category is the first entry (already sorted descending by total)
        String topCategory = categories.isEmpty() ? "None" : categories.get(0).category();

        // Calculate percentage change between the two most recent months
        BigDecimal monthOverMonthChange = calculateMonthOverMonthChange(monthly);

        return new SummaryDTO(totalSpending, topCategory, monthOverMonthChange);
    }

    // Computes (current - previous) / previous * 100 as percentage change
    private BigDecimal calculateMonthOverMonthChange(List<MonthlyTotalDTO> monthly) {
        if (monthly.size() < 2) {
            return BigDecimal.ZERO;
        }
        // Monthly list is sorted chronologically, so last entry is the most recent
        BigDecimal currentMonth = monthly.get(monthly.size() - 1).total();
        BigDecimal previousMonth = monthly.get(monthly.size() - 2).total();

        if (previousMonth.compareTo(BigDecimal.ZERO) == 0) {
            return BigDecimal.ZERO;
        }

        return currentMonth.subtract(previousMonth)
                .divide(previousMonth, 4, RoundingMode.HALF_UP)
                .multiply(BigDecimal.valueOf(100))
                .setScale(1, RoundingMode.HALF_UP);
    }

    // Evicts cached category breakdown when new expenses are added
    @CacheEvict(value = "analytics", key = "#userId + ':categories'")
    public void evictCategoriesCache(UUID userId) {
    }

    // Evicts cached monthly breakdown when new expenses are added
    @CacheEvict(value = "analytics", key = "#userId + ':monthly'")
    public void evictMonthlyCache(UUID userId) {
    }
}

And here is the complete AnalyticsController.java:

package com.nextwork.expense.controller;

import com.nextwork.expense.dto.CategoryTotalDTO;
import com.nextwork.expense.dto.MonthlyTotalDTO;
import com.nextwork.expense.dto.SummaryDTO;
import com.nextwork.expense.service.AnalyticsService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.UUID;

@RestController
@RequestMapping("/api/analytics")
public class AnalyticsController {

    private final AnalyticsService analyticsService;

    public AnalyticsController(AnalyticsService analyticsService) {
        this.analyticsService = analyticsService;
    }

    // GET /api/analytics/categories - returns spending grouped by category
    @GetMapping("/categories")
    public ResponseEntity<List<CategoryTotalDTO>> getCategoryBreakdown() {
        UUID userId = UUID.fromString(
                SecurityContextHolder.getContext().getAuthentication().getName());
        return ResponseEntity.ok(analyticsService.getCategoryBreakdown(userId));
    }

    // GET /api/analytics/monthly - returns spending grouped by year and month
    @GetMapping("/monthly")
    public ResponseEntity<List<MonthlyTotalDTO>> getMonthlyBreakdown() {
        UUID userId = UUID.fromString(
                SecurityContextHolder.getContext().getAuthentication().getName());
        return ResponseEntity.ok(analyticsService.getMonthlyBreakdown(userId));
    }

    // GET /api/analytics/summary - returns total, top category, and month-over-month change
    @GetMapping("/summary")
    public ResponseEntity<SummaryDTO> getSummary() {
        UUID userId = UUID.fromString(
                SecurityContextHolder.getContext().getAuthentication().getName());
        return ResponseEntity.ok(analyticsService.getSummary(userId));
    }
}

Your analytics API is live and cached. Users can now see exactly where their money goes, spot trends across months, and get a quick financial health check. Next up, you will build an AI chatbot endpoint that lets users ask natural language questions about their expenses.

Build the AI Chatbot Endpoint

Your analytics API gives users structured data about their spending. But structured data only answers pre-defined questions.

In this step, you will build an AI chatbot endpoint that lets users ask free-form questions about their expenses in natural language. The chatbot uses Spring AI's ChatClient to pass expense context to the local LLM, so it can answer questions like "How much did I spend on groceries last month?" with personalized answers.

In this step, get ready to:
  • Build a ChatbotService that fetches user expense context and queries the LLM.
  • Create a POST /api/chat endpoint that accepts natural language questions and returns AI answers.
  • Add conversation memory so follow-up questions work naturally.
Create the chat DTOs

The chatbot needs three data transfer objects: one for incoming requests, one for outgoing responses, and one to represent individual messages in conversation history.

  • Create a new file ChatRequest.java in core-service/src/main/java/com/nextwork/expense/dto/ with the following code:
package com.nextwork.expense.dto;

// Record for incoming chat request payload
public record ChatRequest(String question) {
}
  • Create ChatResponse.java in the same dto package:
package com.nextwork.expense.dto;

// Record for outgoing chat response payload
public record ChatResponse(String answer) {
}
  • Create ChatMessage.java in the same dto package:
package com.nextwork.expense.dto;

// Record representing a single chat message with role and content
public record ChatMessage(String role, String content) {
}

What are these records for?

  • ChatRequest carries the user's natural language question into the endpoint.
  • ChatResponse wraps the LLM's answer for the JSON response body.
  • ChatMessage stores a single turn (user or assistant) in conversation history, enabling follow-up questions.
  • Save all three files.
  • Confirm there are no compile errors in IntelliJ by checking the bottom status bar for a green checkmark.
Build the ChatbotService

The ChatbotService is the brain of the chatbot. It gathers the user's expense context from the analytics service, builds a system prompt that tells the LLM who the user is and what they have spent, then passes the user's question to the LLM for a personalized answer.

  • First, create ChatHistoryStore.java in core-service/src/main/java/com/nextwork/expense/service/ with the following code:
package com.nextwork.expense.service;

import com.nextwork.expense.dto.ChatMessage;
import org.springframework.stereotype.Component;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

// Stores conversation history per user for follow-up questions
@Component
public class ChatHistoryStore {

    private final Map<UUID, LinkedList<ChatMessage>> history = new ConcurrentHashMap<>();

    // Adds a message to the user's history, keeping max 10 entries (5 pairs)
    public void addMessage(UUID userId, ChatMessage message) {
        LinkedList<ChatMessage> messages = history.computeIfAbsent(userId, k -> new LinkedList<>());
        messages.add(message);
        if (messages.size() > 10) {
            messages.removeFirst();
        }
    }

    // Returns an unmodifiable view of the user's chat history
    public List<ChatMessage> getHistory(UUID userId) {
        return Collections.unmodifiableList(
            history.getOrDefault(userId, new LinkedList<>())
        );
    }
}

What does this code do?

ChatHistoryStore is a lightweight in-memory store that keeps the last 5 conversation pairs (10 messages total) per user.

It uses a ConcurrentHashMap for thread safety, since multiple requests could update the same user's history simultaneously. When the list exceeds 10 entries, the oldest message is removed automatically.

  • Save ChatHistoryStore.java.
  • Now create ChatbotService.java in the same service package:
package com.nextwork.expense.service;

import com.nextwork.expense.dto.CategoryTotalDTO;
import com.nextwork.expense.dto.ChatMessage;
import com.nextwork.expense.dto.SummaryDTO;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;

// Service that answers natural language questions about user expenses
@Service
public class ChatbotService {

    private final ChatClient chatClient;
    private final AnalyticsService analyticsService;
    private final ChatHistoryStore chatHistoryStore;

    public ChatbotService(ChatClient chatClient, AnalyticsService analyticsService, ChatHistoryStore chatHistoryStore) {
        this.chatClient = chatClient;
        this.analyticsService = analyticsService;
        this.chatHistoryStore = chatHistoryStore;
    }

    // Processes a user question with expense context and conversation history
    public String chat(String question, UUID userId) {
        // Fetch the user's expense data for context
        SummaryDTO summary = analyticsService.getSummary(userId);
        List<CategoryTotalDTO> categories = analyticsService.getCategoryBreakdown(userId);

        // Build category breakdown string
        String categoryBreakdown = categories.stream()
            .map(c -> c.category() + ": $" + c.total())
            .collect(Collectors.joining(", "));

        // Build system prompt with expense context
        String systemPrompt = String.format(
            "You are a helpful expense assistant. The user has total spending of $%s, " +
            "top category %s. Category breakdown: %s. Answer questions about their expenses.",
            summary.totalSpending(), summary.topCategory(), categoryBreakdown
        );

        // Get conversation history for follow-up context
        List<ChatMessage> history = chatHistoryStore.getHistory(userId);
        StringBuilder conversationContext = new StringBuilder();
        for (ChatMessage msg : history) {
            conversationContext.append(msg.role()).append(": ").append(msg.content()).append("\n");
        }

        // Build the full user message with history prepended
        String fullUserMessage = conversationContext.isEmpty()
            ? question
            : conversationContext + "user: " + question;

        // Call the LLM with system prompt and user question
        String answer = chatClient.prompt()
            .system(systemPrompt)
            .user(fullUserMessage)
            .call()
            .content();

        // Store the conversation pair for future follow-ups
        chatHistoryStore.addMessage(userId, new ChatMessage("user", question));
        chatHistoryStore.addMessage(userId, new ChatMessage("assistant", answer));

        return answer;
    }
}

What does this code do?

  • The service fetches the user's spending summary and category breakdown from AnalyticsService to build personalized context.
  • It constructs a system prompt that tells the LLM the user's total spending, top category, and full breakdown. This grounds the LLM's answers in real data.
  • Conversation history is prepended to the user message so the LLM can understand follow-up questions like "What about last month?" in context.
  • After getting the LLM's response, both the question and answer are saved to ChatHistoryStore for future turns.

Why prepend history to the user message?

Spring AI's ChatClient provides a clean fluent API with .system() and .user() methods. By concatenating previous messages into the user prompt, the LLM sees the full conversation flow without needing a more complex message list API.

The 5-pair limit (10 messages) prevents the prompt from growing too large and consuming excessive tokens on the local LLM.

  • Save ChatbotService.java.
  • Verify IntelliJ shows no red underlines in the file. The ChatClient import should resolve since Spring AI Ollama is already configured from Step 4.

ChatClient import not resolving?

Make sure your pom.xml still has the spring-ai-starter-model-ollama dependency and the Spring AI BOM. Reimport your Maven project by right-clicking the pom.xml and selecting Maven then Reload Project.

help me fix my ChatClient import in ChatbotService

Create the chat REST endpoint

With the service layer complete, you need a controller to expose it as a secured REST endpoint. The controller extracts the user's identity from the JWT token and delegates to the ChatbotService.

  • Create ChatController.java in core-service/src/main/java/com/nextwork/expense/controller/ with the following code:
package com.nextwork.expense.controller;

import com.nextwork.expense.dto.ChatRequest;
import com.nextwork.expense.dto.ChatResponse;
import com.nextwork.expense.service.ChatbotService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

import java.util.UUID;

// REST controller for the AI chatbot endpoint
@RestController
@RequestMapping("/api/chat")
public class ChatController {

    private final ChatbotService chatbotService;

    public ChatController(ChatbotService chatbotService) {
        this.chatbotService = chatbotService;
    }

    // Accepts a natural language question and returns an AI-generated answer
    @PostMapping
    public ResponseEntity<ChatResponse> chat(@RequestBody ChatRequest request) {
        // Extract user ID from the JWT token in the security context
        String userId = SecurityContextHolder.getContext().getAuthentication().getName();
        String answer = chatbotService.chat(request.question(), UUID.fromString(userId));
        return ResponseEntity.ok(new ChatResponse(answer));
    }
}

What does this code do?

  • @RequestMapping("/api/chat") maps all endpoints in this controller under the /api/chat path.
  • The @PostMapping method deserializes the JSON body into a ChatRequest record automatically.
  • SecurityContextHolder gives access to the authenticated user's ID from the JWT, following the same pattern as AnalyticsController.
  • Save ChatController.java.
  • Make sure Ollama is running by checking that http://localhost:11434 responds in your browser. You should see "Ollama is running".
  • Start your Spring Boot application and test the endpoint with curl by running:
curl -X POST http://localhost:8080/api/chat \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-jwt-token-here" \
  -d '{"question": "What is my top spending category?"}'

You should see a JSON response containing an answer field with the LLM's natural language response about your expenses.

Getting a JWT for testing

Use your login endpoint (POST /api/auth/login) first to get a valid JWT token. Copy the token from the response and paste it in place of your-jwt-token-here in the curl command above.

Getting a 401 or 403 response?

Verify your JWT token is valid and not expired. Make sure /api/chat/** is included in your Spring Security filter chain as an authenticated endpoint.

help me debug why my /api/chat endpoint returns a 401 or 403

LLM response is very slow or timing out?

The first request after starting Ollama can take 20-30 seconds as the model loads into memory. Subsequent requests should be faster. If you keep getting timeouts, confirm llama3.1:8b is pulled by running ollama list in your terminal.

help me troubleshoot why my Ollama LLM is timing out when called from Spring AI

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files.

ⓧ I'd like to double check the full code

Here are all the files you created in this step:

package com.nextwork.expense.dto;

// Record for incoming chat request payload
public record ChatRequest(String question) {
}
package com.nextwork.expense.dto;

// Record for outgoing chat response payload
public record ChatResponse(String answer) {
}
package com.nextwork.expense.dto;

// Record representing a single chat message with role and content
public record ChatMessage(String role, String content) {
}
package com.nextwork.expense.service;

import com.nextwork.expense.dto.ChatMessage;
import org.springframework.stereotype.Component;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

// Stores conversation history per user for follow-up questions
@Component
public class ChatHistoryStore {

    private final Map<UUID, LinkedList<ChatMessage>> history = new ConcurrentHashMap<>();

    // Adds a message to the user's history, keeping max 10 entries (5 pairs)
    public void addMessage(UUID userId, ChatMessage message) {
        LinkedList<ChatMessage> messages = history.computeIfAbsent(userId, k -> new LinkedList<>());
        messages.add(message);
        if (messages.size() > 10) {
            messages.removeFirst();
        }
    }

    // Returns an unmodifiable view of the user's chat history
    public List<ChatMessage> getHistory(UUID userId) {
        return Collections.unmodifiableList(
            history.getOrDefault(userId, new LinkedList<>())
        );
    }
}
package com.nextwork.expense.service;

import com.nextwork.expense.dto.CategoryTotalDTO;
import com.nextwork.expense.dto.ChatMessage;
import com.nextwork.expense.dto.SummaryDTO;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;

// Service that answers natural language questions about user expenses
@Service
public class ChatbotService {

    private final ChatClient chatClient;
    private final AnalyticsService analyticsService;
    private final ChatHistoryStore chatHistoryStore;

    public ChatbotService(ChatClient chatClient, AnalyticsService analyticsService, ChatHistoryStore chatHistoryStore) {
        this.chatClient = chatClient;
        this.analyticsService = analyticsService;
        this.chatHistoryStore = chatHistoryStore;
    }

    // Processes a user question with expense context and conversation history
    public String chat(String question, UUID userId) {
        // Fetch the user's expense data for context
        SummaryDTO summary = analyticsService.getSummary(userId);
        List<CategoryTotalDTO> categories = analyticsService.getCategoryBreakdown(userId);

        // Build category breakdown string
        String categoryBreakdown = categories.stream()
            .map(c -> c.category() + ": $" + c.total())
            .collect(Collectors.joining(", "));

        // Build system prompt with expense context
        String systemPrompt = String.format(
            "You are a helpful expense assistant. The user has total spending of $%s, " +
            "top category %s. Category breakdown: %s. Answer questions about their expenses.",
            summary.totalSpending(), summary.topCategory(), categoryBreakdown
        );

        // Get conversation history for follow-up context
        List<ChatMessage> history = chatHistoryStore.getHistory(userId);
        StringBuilder conversationContext = new StringBuilder();
        for (ChatMessage msg : history) {
            conversationContext.append(msg.role()).append(": ").append(msg.content()).append("\n");
        }

        // Build the full user message with history prepended
        String fullUserMessage = conversationContext.isEmpty()
            ? question
            : conversationContext + "user: " + question;

        // Call the LLM with system prompt and user question
        String answer = chatClient.prompt()
            .system(systemPrompt)
            .user(fullUserMessage)
            .call()
            .content();

        // Store the conversation pair for future follow-ups
        chatHistoryStore.addMessage(userId, new ChatMessage("user", question));
        chatHistoryStore.addMessage(userId, new ChatMessage("assistant", answer));

        return answer;
    }
}
package com.nextwork.expense.controller;

import com.nextwork.expense.dto.ChatRequest;
import com.nextwork.expense.dto.ChatResponse;
import com.nextwork.expense.service.ChatbotService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

import java.util.UUID;

// REST controller for the AI chatbot endpoint
@RestController
@RequestMapping("/api/chat")
public class ChatController {

    private final ChatbotService chatbotService;

    public ChatController(ChatbotService chatbotService) {
        this.chatbotService = chatbotService;
    }

    // Accepts a natural language question and returns an AI-generated answer
    @PostMapping
    public ResponseEntity<ChatResponse> chat(@RequestBody ChatRequest request) {
        // Extract user ID from the JWT token in the security context
        String userId = SecurityContextHolder.getContext().getAuthentication().getName();
        String answer = chatbotService.chat(request.question(), UUID.fromString(userId));
        return ResponseEntity.ok(new ChatResponse(answer));
    }
}

Secret mission

Stripe Webhook for Payment Status

Your payment integration creates PaymentIntents, but how does your system know when a payment actually succeeds or fails? In production, Stripe sends real-time status updates via webhooks. Build the webhook handler that closes the loop.

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 Docker containers, so there are no ongoing cloud costs.

Resources you used:

  • Docker containers (Redis, RabbitMQ).
  • PostgreSQL database with project tables (users, invoices, expenses, payments).
  • Ollama running locally with the llama3.1:8b model (~4.7GB on disk).
  • Spring Boot applications (API Gateway module + Core Service module).
  • Stripe test account (free, no charges).
  • Local project files (Maven multi-module project on your Desktop).

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment further with the AI chatbot, analytics endpoints, or Stripe webhooks.

  • Your Docker containers (Redis, RabbitMQ) will continue running and consuming memory.
  • Ollama will remain available as a background service on port 11434.
  • Your Spring Boot applications will stay runnable from IntelliJ IDEA.

Pause - I'll come back to this later

Shut down running processes to free up memory and CPU. Your project files and database data stay intact for when you return.

  • Stop your Spring Boot applications in IntelliJ IDEA by clicking the red square stop button.
  • Stop the Docker containers by running docker compose down from your project directory. This stops Redis and RabbitMQ but preserves their data volumes.
  • Stop Ollama by running ollama stop llama3.1:8b in your terminal. The model files stay on disk.
  • Your PostgreSQL data persists between restarts. No action needed there.
  • To resume later, run docker compose up -d and start your Spring Boot apps again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This frees up significant disk space, especially the Ollama model.

  • Stop and remove all Docker containers and volumes by running docker compose down -v from your project directory.
  • Remove the Ollama model by running ollama rm llama3.1:8b to reclaim ~4.7GB of disk space.
  • Drop the project database tables in PostgreSQL, or drop the entire database if you created one specifically for this project.
  • Delete the project folder from your Desktop.
  • Your Stripe test account can remain active at no cost. Delete it from the Stripe dashboard if you prefer.
  • Optionally uninstall Tesseract by running brew uninstall tesseract if you no longer need OCR capabilities.

That's a Wrap!

That's a Wrap!

Nice work! You have built a production-grade fintech backend service that demonstrates the exact system design patterns Solutions Architects discuss in real-world interviews.

Here's what you learned:

  • Built an AI-powered invoice processing pipeline that extracts text from images via Tesseract OCR and categorizes expenses with a local LLM (Ollama) through Spring AI.
  • Implemented enterprise resilience patterns including Resilience4j circuit breakers, Redis caching, RabbitMQ async processing, and a Spring Cloud Gateway with Token Bucket rate limiting.
  • Designed a fintech backend with JWT authentication, Stripe payment integration, spending analytics APIs, an AI chatbot endpoint, and a sharding-ready database schema.
  • Secret Mission: Added a Stripe webhook endpoint that receives payment status updates and automatically marks invoices as paid in the database.

Ready to quiz yourself?