Double-Entry Ledger API with Plaid
Build a Spring Boot REST API that enforces double-entry bookkeeping with Plaid.
Introduction
30 Second Summary
Every time you swipe your card, two things happen at once. Your bank balance drops and a merchant gets paid.
In this project, you will build a Spring Boot REST API that enforces this two-sided rule for every transaction. You will connect it to Plaid's sandbox bank to pull test transactions and automatically convert them into balanced ledger entries backed by PostgreSQL.
What You'll Build
Picture yourself firing off API requests, watching test bank transactions flow in from a fake bank called "First Platypus Bank," and seeing each one automatically split into a balanced debit-and-credit pair in your ledger.
By the end of this project, you'll have:
- A live API where you can browse your chart of accounts organized by type (Asset, Liability, Expense, Income, Equity) and add new accounts on the fly.
- A balance validation system that rejects any journal entry where debits don't equal credits. You can post an unbalanced entry and watch the API refuse it, proving the core rule of double-entry bookkeeping in real time.
- A Plaid sandbox integration that pulls test transactions from a fake bank and automatically converts each one into a properly balanced journal entry in your ledger.
- Secret Mission: an optional challenge to push your skills even further.
Are there any prerequisites?
This project assumes you have Java 21, an IDE (like IntelliJ IDEA), and PostgreSQL running in Docker. You will also need Node.js installed for a future React frontend.
Before We Start
Before we write any code, let's lock in what you're about to build and why it matters.
Scaffold the Spring Boot Project
Before you can write any ledger logic, you need a working Spring Boot project wired to your PostgreSQL database. Getting the scaffold running with a successful startup proves your entire development environment works end to end.
You will use Spring Initializr to generate a Maven project with the right dependencies, then add the Plaid Java SDK and point the app at your Docker PostgreSQL instance.
In this step, get ready to:
- Generate a Spring Boot project with Spring Initializr.
- Add the Plaid Java SDK and configure your database connection.
- Create a package-by-feature project structure.
Generate a Spring Boot project with Spring Initializr
Spring Initializr is a web tool that generates a ready-to-run Spring Boot project with all the boilerplate set up for you. You pick your build tool, language, and dependencies, and it gives you a downloadable zip.
- Open https://start.spring.io in your browser.
- Under Project, select Maven.
- Under Language, select Java.
- Under Spring Boot, select 3.5.16 (or the latest available 3.5.x version that is not a SNAPSHOT).
- Set Group to com.ledger.
- Set Artifact to ledger-api.
- Set Java to 21.
What about the other fields?
Leave Name, Description, Package name, and Packaging at their defaults. Spring Initializr auto-fills these based on your Group and Artifact.
- Click ADD DEPENDENCIES.
- Search for Spring Web and select it.
- Search for Spring Data JPA and select it.
- Search for PostgreSQL Driver and select it.
- Click Generate to download the zip file.
- Extract the downloaded ledger-api.zip to a location you can find easily (your Desktop works well).
- In your IDE, open the extracted ledger-api folder as a project.
Your IDE will detect the pom.xml and import the Maven project automatically. You should see the src/main/java/com/ledger/ledgerapi package with a LedgerApiApplication.java file inside.
Don't see the project structure?
Make sure you opened the ledger-api folder itself (the one containing pom.xml), not a parent folder. If your IDE asks whether to trust the project or import as a Maven project, accept.
Help me find my project files
Add the Plaid dependency and configure your database
Spring Initializr gave you the core Spring dependencies, but your project also needs the Plaid Java SDK for bank transaction integration. You also need to tell Spring Boot where your PostgreSQL database lives.
First, your Docker PostgreSQL instance needs a database for the ledger API to use.
- Find your PostgreSQL container name by running this command in your terminal:
docker ps
- Look for the PostgreSQL container in the output. Note its name or container ID.
- Create the ledger database by running this command (replacing the container identifier):
docker exec -it [[POSTGRES_CONTAINER="your PostgreSQL container name or ID"]] psql -U postgres -c "CREATE DATABASE ledger;"
You should see CREATE DATABASE in the output, confirming the database was created.
Seeing "database already exists"?
That is fine. It means you already have a ledger database from a previous attempt. You can continue to the next instruction.
Help me fix this Docker error
- In your IDE, open pom.xml and find the <dependencies> section.
- Add the Plaid Java SDK dependency by pasting this block inside <dependencies>, after the existing dependencies:
<!-- Plaid Java SDK for bank transaction integration -->
<dependency>
<groupId>com.plaid</groupId>
<artifactId>plaid-java</artifactId>
<version>44.0.0</version>
</dependency>
What does this dependency do?
The plaid-java SDK provides pre-built Java classes for calling the Plaid API. It handles authentication, request building, and response parsing so you do not have to write raw HTTP calls.
Version 44.0.0 is the latest release on Maven Central.
- Save pom.xml. Your IDE should prompt you to reload the Maven project. Accept the reload to download the new dependency.
Now configure the database connection. Spring Boot reads its configuration from application.properties at startup.
- Open src/main/resources/application.properties and replace any existing content with the following:
# Connect to the Docker PostgreSQL database
spring.datasource.url=jdbc:postgresql://localhost:5432/ledger
spring.datasource.username=postgres
spring.datasource.password=postgres
# Auto-create tables from JPA entities
spring.jpa.hibernate.ddl-auto=update
# Log SQL queries to the console for debugging
spring.jpa.show-sql=true
What do these properties mean?
spring.datasource.url tells Spring Boot the JDBC connection string for your Docker PostgreSQL instance. The ledger at the end is the database name you just created.
spring.jpa.hibernate.ddl-auto=update tells JPA to automatically create or update database tables to match your Java entity classes. This is ideal for development.
spring.jpa.show-sql=true prints every SQL query to the console so you can see exactly what Hibernate is doing.
- Save application.properties.
What do you think will happen when you start the app? It has a database connection configured and JPA is set up, but no entity classes exist yet.
- Run the application with the Maven wrapper by running this command from the ledger-api folder:
macOS
./mvnw spring-boot:run
Windows
.\mvnw spring-boot:run
The first run downloads all Maven dependencies, so it may take a minute. When it finishes, you should see Started LedgerApiApplication in the console output, and the app listens on port 8080.
Seeing a connection refused error?
Make sure your Docker PostgreSQL container is running. Run docker ps and check that the container is listed and port 5432 is mapped.
Also confirm the database name in your application.properties matches the one you created. The URL should end with /ledger.
Help me debug this connection error
- Stop the running app by pressing Ctrl+C in the terminal.
Create the package-by-feature structure
Enterprise Java projects organize code by feature, not by layer. Instead of dumping all controllers into one folder and all services into another, each domain concept gets its own package containing everything it needs.
Why package-by-feature instead of package-by-layer?
Package-by-layer (one folder for all controllers, one for all services) forces you to jump between folders constantly. Package-by-feature keeps related code together. When you open the account package, you see the controller, service, repository, and entity for accounts all in one place.
This is the structure recommended for production Spring Boot codebases.
You need four packages under com.ledger.ledgerapi: account, journal, plaid, and shared.
- In your IDE's Project tool window, expand src/main/java/com/ledger/ledgerapi.
- Right-click the ledgerapi package.
- Select New, then Package.
- Type account and press Enter.
- Repeat the same process to create three more packages: journal, plaid, and shared.
Keyboard shortcut for new packages
Instead of right-clicking, select the ledgerapi package and press Cmd+N (macOS) or Alt+Insert (Windows/Linux). Then choose Package.
Your project tree should now show four sub-packages under ledgerapi: account, journal, plaid, and shared. Each is empty for now. You will fill them with controllers, services, repositories, and entities in the upcoming steps.
Packages not visible in the project tree?
Some IDEs hide empty packages by default. In IntelliJ IDEA, click the gear icon at the top of the Project tool window and make sure Compact Middle Packages is unchecked. This ensures each package level is shown individually.
Help me make my packages visible
✔️ Awesome, I've got everything!
Great. Double check you have saved both pom.xml and application.properties.
ⓧ I'd like to double check the full code
Your application.properties should contain exactly this:
# Connect to the Docker PostgreSQL database
spring.datasource.url=jdbc:postgresql://localhost:5432/ledger
spring.datasource.username=postgres
spring.datasource.password=postgres
# Auto-create tables from JPA entities
spring.jpa.hibernate.ddl-auto=update
# Log SQL queries to the console for debugging
spring.jpa.show-sql=true
Your pom.xml should have these four dependencies inside the <dependencies> block (Spring Initializr generated the first three, you added the fourth):
- spring-boot-starter-web.
- spring-boot-starter-data-jpa.
- postgresql.
- plaid-java version 44.0.0.
Your project tree should show four empty sub-packages under com.ledger.ledgerapi: account, journal, plaid, and shared.
Your Spring Boot project is scaffolded, connected to PostgreSQL, and organized for production-style development. Next up, you will fill the account package with your first REST API endpoint and see real data come back from the database.
Build the Chart of Accounts
Your Spring Boot project is scaffolded, connected to PostgreSQL, and running on port 8080. That means your development environment works end to end.
A double-entry ledger needs accounts to post entries against. Without accounts, there is nothing to debit or credit. In this step, you will build the chart of accounts, which is a structured list of every account your ledger tracks, complete with a REST API to view and create them.
In this step, get ready to:
- Create a JPA entity for accounts with types like Asset, Liability, and Expense.
- Build a REST API with GET and POST endpoints for managing accounts.
- Seed six default accounts that load automatically on startup.
Define the account model
Every account in a chart of accounts has a type that determines how it behaves in the ledger. Assets, liabilities, equity, income, and expenses are the five fundamental categories of double-entry bookkeeping.
- In your IDE, right-click the account package (src/main/java/com/ledger/ledgerapi/account/) and create a new Java file called AccountType.java.
- Paste the following enum into the file:
package com.ledger.ledgerapi.account;
// The five fundamental account categories in double-entry bookkeeping
public enum AccountType {
ASSET, LIABILITY, EQUITY, INCOME, EXPENSE
}
What is an enum?
An enum is a Java type that represents a fixed set of constants. Using an enum for account types means the compiler catches typos like "ASST" before you ever run the app.
Now you need a JPA entity that maps to a database table. Each account will have a unique ID, a human-readable name, a short code (like 1000), and one of the account types you just defined.
- Create a new Java file called Account.java in the same account package.
- Paste the following entity class:
package com.ledger.ledgerapi.account;
import jakarta.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "accounts")
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id; // Auto-generated unique identifier
private String name;
private String code; // Short identifier like "1000"
@Enumerated(EnumType.STRING)
private AccountType accountType; // Stored as text in the database
public Account() {}
public Account(String name, String code, AccountType accountType) {
this.name = name;
this.code = code;
this.accountType = accountType;
}
public UUID getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getCode() { return code; }
public void setCode(String code) { this.code = code; }
public AccountType getAccountType() { return accountType; }
public void setAccountType(AccountType accountType) { this.accountType = accountType; }
}
What does this code do?
- @Entity and @Table(name = "accounts") tell Hibernate to create a database table called accounts and map each field to a column.
- @GeneratedValue(strategy = GenerationType.UUID) makes the database generate a unique UUID for each new account automatically.
- @Enumerated(EnumType.STRING) stores the account type as readable text (like ASSET) instead of a number.
- The empty constructor is required by JPA. The parameterized constructor is a convenience for creating accounts in your own code.
- Save both AccountType.java and Account.java.
- If your app is still running from Step 1, stop it by pressing Ctrl+C in your terminal.
- Restart the app by running:
mvn spring-boot:run
Watch the console output. Because spring.jpa.show-sql=true is set in your application.properties, you should see Hibernate SQL statements that create the accounts table in PostgreSQL.
App fails to start?
- Check that AccountType.java and Account.java are both in the com.ledger.ledgerapi.account package. The Account entity imports AccountType from the same package.
- Make sure your Docker PostgreSQL container is still running. The app cannot create tables if the database is unreachable.
Need more help? Help me debug this Spring Boot startup error with my Account entity.
Add the repository, service, and controller
The entity defines your data shape, but you need three more layers to serve it over HTTP: a repository to talk to the database, a service for business logic, and a controller to handle web requests.
- Stop the running app with Ctrl+C.
- Create a new file called AccountRepository.java in the account package and paste this interface:
package com.ledger.ledgerapi.account;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
// Spring Data JPA generates the database queries at runtime
public interface AccountRepository extends JpaRepository<Account, UUID> {
}
What is Spring Data JPA doing here?
By extending JpaRepository, you get methods like findAll(), save(), and count() for free. Spring Data JPA generates the full implementation at runtime, so you never write a single SQL query by hand for basic operations.
- Create AccountService.java in the account package and paste the following service class:
package com.ledger.ledgerapi.account;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class AccountService {
private final AccountRepository accountRepository;
// Spring injects the repository automatically
public AccountService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
// Return every account in the chart
public List<Account> findAll() {
return accountRepository.findAll();
}
// Persist a new account to the database
public Account save(Account account) {
return accountRepository.save(account);
}
}
What does this code do?
- @Service marks this class as a Spring-managed bean. Spring creates a single instance and wires it wherever it is needed.
- The constructor takes AccountRepository as a parameter. Spring recognizes this pattern and injects the repository automatically.
- findAll() and save() delegate to the repository. Keeping this thin service layer lets you add business logic later without touching the controller.
- Save AccountRepository.java and AccountService.java.
Now you need a controller to expose these operations as HTTP endpoints.
- Create AccountController.java in the account package and paste the following:
package com.ledger.ledgerapi.account;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/accounts")
public class AccountController {
private final AccountService accountService;
public AccountController(AccountService accountService) {
this.accountService = accountService;
}
// GET /api/accounts - return all accounts as JSON
@GetMapping
public List<Account> getAllAccounts() {
return accountService.findAll();
}
// POST /api/accounts - create a new account
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Account createAccount(@RequestBody Account account) {
return accountService.save(account);
}
}
What does this code do?
- @RestController tells Spring this class handles HTTP requests and returns JSON responses automatically.
- @RequestMapping("/api/accounts") sets the base URL. All endpoints in this controller live under /api/accounts.
- @ResponseStatus(HttpStatus.CREATED) on the POST endpoint returns a 201 status code instead of the default 200, which is the correct HTTP status for creating a resource.
- Save AccountController.java.
- Restart the app by running:
mvn spring-boot:run
- Once you see "Started LedgerApiApplication", open your browser and go to http://localhost:8080/api/accounts.
You should see an empty JSON array: []. The endpoint works, but there are no accounts in the database yet.
Seeing a Whitelabel Error Page instead of JSON?
- Double-check that your URL is exactly http://localhost:8080/api/accounts. A wrong path returns the default Spring Boot error page.
- Verify that AccountController.java is in the com.ledger.ledgerapi.account package. Spring only scans packages under com.ledger.ledgerapi automatically.
Still stuck? Help me debug why my /api/accounts endpoint returns a Whitelabel Error Page.
✔️ Awesome, I've got everything!
Great. Make sure all four files (AccountType.java, Account.java, AccountRepository.java, AccountService.java, AccountController.java) are saved in the account package.
ⓧ I'd like to double check the full code
Scroll back up and confirm each of these files is present in order:
- AccountType.java with the five enum values.
- Account.java with the @Entity annotation, four fields, two constructors, and getters/setters.
- AccountRepository.java extending JpaRepository<Account, UUID>.
- AccountService.java with findAll() and save() methods.
- AccountController.java with @GetMapping and @PostMapping endpoints.
Seed default accounts on startup
An empty chart of accounts is not very useful. You need a set of starter accounts so you can begin posting journal entries right away in the next step.
- Stop the running app with Ctrl+C.
- In the shared package (src/main/java/com/ledger/ledgerapi/shared/), create a new file called DataSeeder.java and paste the following:
package com.ledger.ledgerapi.shared;
import com.ledger.ledgerapi.account.Account;
import com.ledger.ledgerapi.account.AccountRepository;
import com.ledger.ledgerapi.account.AccountType;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class DataSeeder implements ApplicationRunner {
private final AccountRepository accountRepository;
public DataSeeder(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
@Override
public void run(ApplicationArguments args) {
// Only seed if the table is empty
if (accountRepository.count() > 0) {
return;
}
// Seed the default chart of accounts
accountRepository.save(new Account("Cash", "1000", AccountType.ASSET));
accountRepository.save(new Account("Checking", "1010", AccountType.ASSET));
accountRepository.save(new Account("Accounts Payable", "2000", AccountType.LIABILITY));
accountRepository.save(new Account("Food & Dining", "5000", AccountType.EXPENSE));
accountRepository.save(new Account("Shopping", "5010", AccountType.EXPENSE));
accountRepository.save(new Account("Income", "4000", AccountType.INCOME));
}
}
What does this code do?
- ApplicationRunner is a Spring Boot interface. Any class that implements it runs automatically after the app finishes starting up.
- accountRepository.count() > 0 prevents duplicate seeding. If you restart the app, it skips seeding because the accounts already exist in the database.
- The six accounts cover four of the five account types: Asset, Liability, Expense, and Income. You will use these accounts to post journal entries in the next step.
- Save DataSeeder.java.
✔️ Awesome, I've got everything!
Great. Double check that DataSeeder.java is saved in the shared package, not the account package.
ⓧ I'd like to double check the full code
package com.ledger.ledgerapi.shared;
import com.ledger.ledgerapi.account.Account;
import com.ledger.ledgerapi.account.AccountRepository;
import com.ledger.ledgerapi.account.AccountType;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class DataSeeder implements ApplicationRunner {
private final AccountRepository accountRepository;
public DataSeeder(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
@Override
public void run(ApplicationArguments args) {
if (accountRepository.count() > 0) {
return;
}
accountRepository.save(new Account("Cash", "1000", AccountType.ASSET));
accountRepository.save(new Account("Checking", "1010", AccountType.ASSET));
accountRepository.save(new Account("Accounts Payable", "2000", AccountType.LIABILITY));
accountRepository.save(new Account("Food & Dining", "5000", AccountType.EXPENSE));
accountRepository.save(new Account("Shopping", "5010", AccountType.EXPENSE));
accountRepository.save(new Account("Income", "4000", AccountType.INCOME));
}
}
Before you restart the app, take a moment to predict: what do you think GET /api/accounts will return now?
- Restart the app by running:
mvn spring-boot:run
- Once you see "Started LedgerApiApplication" in the console, open your browser and go to http://localhost:8080/api/accounts.
You should see a JSON array with all six seeded accounts, each showing their id, name, code, and accountType. The DataSeeder ran on startup and populated the database.
Still seeing an empty array?
- Make sure DataSeeder.java has the @Component annotation. Without it, Spring does not know the class exists.
- Check the package declaration is com.ledger.ledgerapi.shared. A class in a package outside com.ledger.ledgerapi will not be picked up by Spring's component scan.
- If you restarted more than once and cleared the database in between, the count() > 0 check may be seeing zero rows. Restart the app one more time and the seeder will run.
Need more help? Help me debug why my DataSeeder is not populating the accounts table on startup.
Your chart of accounts is live and serving data over HTTP. Next up, you will build journal entries with the core rule of double-entry bookkeeping: every entry must balance, with total debits equaling total credits.
Create Journal Entries with Balance Validation
Your chart of accounts is live and returning data from PostgreSQL. Now you need something to post against those accounts.
The core rule of double-entry bookkeeping is that every journal entry must balance: total debits must equal total credits. Without this validation, the ledger is just a list of numbers. In this step, you will build journal entries with a balance check that rejects any entry where the two sides do not match.
In this step, get ready to:
- Create the JournalEntry and JournalEntryLine JPA entities that model double-entry records.
- Build a service that validates debits equal credits before saving.
- Test the API by submitting an unbalanced entry (rejected) and a balanced entry (accepted).
Create the JournalEntry entity
A journal entry is the container for one balanced transaction. It holds a date, a description, and a list of line items that each target a specific account with a debit or credit amount.
- In the journal package (src/main/java/com/ledger/ledgerapi/journal/), create a new file called JournalEntry.java and paste the following code:
package com.ledger.ledgerapi.journal;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.*;
@Entity
@Table(name = "journal_entries")
public class JournalEntry {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private LocalDate date;
private String description;
// Each entry owns its debit/credit line items
@OneToMany(mappedBy = "journalEntry", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonManagedReference
private List<JournalEntryLine> lines = new ArrayList<>();
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public LocalDate getDate() { return date; }
public void setDate(LocalDate date) { this.date = date; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public List<JournalEntryLine> getLines() { return lines; }
public void setLines(List<JournalEntryLine> lines) { this.lines = lines; }
}
What does this code do?
- @Entity and @Table tell JPA to map this class to a journal_entries table in PostgreSQL.
- @OneToMany creates a parent-child relationship. Each JournalEntry owns a list of JournalEntryLine items that get saved and deleted together (cascade = CascadeType.ALL).
- @JsonManagedReference prevents infinite loops when Jackson serializes the parent-child relationship to JSON.
- Save JournalEntry.java.
Create the JournalEntryLine entity
Each line item in a journal entry targets one account and carries either a debit amount or a credit amount. A balanced entry has lines whose debits sum to the same total as the credits.
- In the same journal package, create JournalEntryLine.java and paste the following code:
package com.ledger.ledgerapi.journal;
import com.fasterxml.jackson.annotation.JsonBackReference;
import com.ledger.ledgerapi.account.Account;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.UUID;
@Entity
@Table(name = "journal_entry_lines")
public class JournalEntryLine {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
// Links back to the parent journal entry
@ManyToOne
@JoinColumn(name = "journal_entry_id")
@JsonBackReference
private JournalEntry journalEntry;
// The account this line debits or credits
@ManyToOne
@JoinColumn(name = "account_id")
private Account account;
private BigDecimal debitAmount;
private BigDecimal creditAmount;
- Below the field declarations but still inside the class, add the getters and setters:
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public JournalEntry getJournalEntry() { return journalEntry; }
public void setJournalEntry(JournalEntry je) { this.journalEntry = je; }
public Account getAccount() { return account; }
public void setAccount(Account account) { this.account = account; }
public BigDecimal getDebitAmount() { return debitAmount; }
public void setDebitAmount(BigDecimal d) { this.debitAmount = d; }
public BigDecimal getCreditAmount() { return creditAmount; }
public void setCreditAmount(BigDecimal c) { this.creditAmount = c; }
}
What does this code do?
- @ManyToOne on journalEntry creates the child side of the relationship. Many lines belong to one journal entry.
- @JsonBackReference marks the back-reference that Jackson skips during serialization. This is the counterpart to @JsonManagedReference on the parent, and it prevents the infinite JSON loop.
- The second @ManyToOne on account links each line to a specific Account from your chart of accounts.
- debitAmount and creditAmount use BigDecimal instead of double because floating-point math introduces rounding errors in currency calculations.
- Save JournalEntryLine.java.
- Restart your app by stopping the running process and running it again:
mvn spring-boot:run
In the console output, look for Hibernate's SQL logs. You should see create table journal_entries and create table journal_entry_lines statements. That confirms JPA picked up both entities and created the tables in PostgreSQL.
Not seeing the table creation logs?
- Make sure spring.jpa.show-sql=true is in your application.properties. Without it, Hibernate runs the SQL silently.
- If you see a compilation error, check that JournalEntryLine.java imports com.ledger.ledgerapi.account.Account. The cross-package import is easy to miss.
- If the tables already existed from a previous run, Hibernate may skip the create statement. That is fine. The app starting without errors confirms the entities are valid.
Why aren't my JPA tables being created?
Build the service with balance validation
Before the service can accept journal entries, you need a request shape for the incoming JSON and repositories for database access. These are small supporting files.
- In the journal package, create JournalEntryRequest.java and paste this code:
package com.ledger.ledgerapi.journal;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
// Defines the JSON shape the API accepts for new journal entries
public record JournalEntryRequest(
String description,
LocalDate date,
List<LineRequest> lines
) {
// Each line targets one account with a debit or credit amount
public record LineRequest(
UUID accountId,
BigDecimal debitAmount,
BigDecimal creditAmount
) {}
}
What is a Java record?
A record is a compact Java class that holds data. It automatically generates a constructor, getters, and toString() for you. Records are ideal for request/response shapes because they are immutable and concise.
- Save JournalEntryRequest.java.
- Still in the journal package, create JournalEntryRepository.java with the following code:
package com.ledger.ledgerapi.journal;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface JournalEntryRepository extends JpaRepository<JournalEntry, UUID> {}
- Create one more repository file called JournalEntryLineRepository.java in the same package:
package com.ledger.ledgerapi.journal;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface JournalEntryLineRepository extends JpaRepository<JournalEntryLine, UUID> {}
- Save both repository files.
Now for the heart of this step. The service is where the balance validation lives. It will reject any journal entry where total debits do not equal total credits.
- Create JournalEntryService.java in the journal package and paste this class shell:
package com.ledger.ledgerapi.journal;
import com.ledger.ledgerapi.account.AccountRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
@Service
public class JournalEntryService {
private final JournalEntryRepository journalEntryRepository;
private final AccountRepository accountRepository;
// Spring injects both repositories automatically
public JournalEntryService(JournalEntryRepository repo,
AccountRepository accountRepo) {
this.journalEntryRepository = repo;
this.accountRepository = accountRepo;
}
public List<JournalEntry> findAll() {
return journalEntryRepository.findAll();
}
}
What does this code do?
The service depends on two repositories. JournalEntryRepository saves and retrieves journal entries. AccountRepository looks up accounts by ID so each line item can be linked to the correct account. Spring Boot injects both through the constructor.
- Inside the JournalEntryService class, add the createEntry method above the findAll method:
public JournalEntry createEntry(JournalEntryRequest request) {
// Sum all debit amounts across every line
BigDecimal totalDebits = request.lines().stream()
.map(JournalEntryRequest.LineRequest::debitAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// Sum all credit amounts across every line
BigDecimal totalCredits = request.lines().stream()
.map(JournalEntryRequest.LineRequest::creditAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// Core double-entry rule: reject if debits != credits
if (totalDebits.compareTo(totalCredits) != 0) {
throw new IllegalArgumentException(
"Entry not balanced: debits=" + totalDebits
+ ", credits=" + totalCredits);
}
// Build the journal entry from the request
JournalEntry entry = new JournalEntry();
entry.setDate(request.date());
entry.setDescription(request.description());
// Convert each request line into a JournalEntryLine
List<JournalEntryLine> lines = request.lines().stream()
.map(lr -> {
JournalEntryLine line = new JournalEntryLine();
line.setJournalEntry(entry);
line.setAccount(accountRepository.findById(
lr.accountId()).orElseThrow());
line.setDebitAmount(lr.debitAmount());
line.setCreditAmount(lr.creditAmount());
return line;
}).toList();
entry.setLines(lines);
return journalEntryRepository.save(entry);
}
Why compareTo instead of equals?
BigDecimal's equals() considers scale, so 50.0 and 50.00 would be "not equal" even though they are the same amount. compareTo() compares numerically and ignores scale, which is what you want for currency.
- Save JournalEntryService.java.
- Restart your app to confirm everything compiles cleanly by running:
mvn spring-boot:run
You should see "Started LedgerApiApplication" with no errors. The service is wired up, but you need a controller to expose it over HTTP.
Seeing a compilation error?
- If the error mentions AccountRepository, check that JournalEntryService.java has the import com.ledger.ledgerapi.account.AccountRepository at the top.
- If you see "cannot find symbol" for JournalEntryRequest, make sure all four files (JournalEntryRequest.java, both repositories, and the service) are in the same com.ledger.ledgerapi.journal package.
Help me debug my Spring Boot compilation error.
- Create the controller file JournalEntryController.java in the journal package and paste this code:
package com.ledger.ledgerapi.journal;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/journal-entries")
public class JournalEntryController {
private final JournalEntryService journalEntryService;
public JournalEntryController(JournalEntryService service) {
this.journalEntryService = service;
}
// POST creates a new entry, returning 201 or 400
@PostMapping
public ResponseEntity<?> createEntry(
@RequestBody JournalEntryRequest request) {
try {
JournalEntry entry = journalEntryService.createEntry(request);
return ResponseEntity.status(HttpStatus.CREATED).body(entry);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
@GetMapping
public List<JournalEntry> getAllEntries() {
return journalEntryService.findAll();
}
}
What does this controller do?
- The @PostMapping wraps the service call in a try/catch. If the entry is balanced, it returns HTTP 201 Created. If the service throws an IllegalArgumentException (debits do not equal credits), it returns HTTP 400 Bad Request with the error message.
- The @GetMapping returns all saved journal entries with their lines, so you can inspect what has been posted.
- Save JournalEntryController.java.
Test balance validation with an unbalanced entry
Time to prove the validation works. You will first submit an entry where debits and credits do not match, and the API should reject it.
- Restart your app by running:
mvn spring-boot:run
- You need the UUID of two accounts to build a journal entry. Fetch your accounts by opening this URL in your browser:
http://localhost:8080/api/accounts
- Find the id values for "Food & Dining" and "Checking" in the JSON response. Copy each UUID.
Before you run the next command, think about what should happen. You are about to send a journal entry where you debit $50 but only credit $30. Do you think the API will accept it or reject it?
- Open a new terminal tab (keep the app running) and submit this deliberately unbalanced entry by running:
curl -X POST http://localhost:8080/api/journal-entries \
-H "Content-Type: application/json" \
-d '{
"description": "Lunch",
"date": "2026-08-21",
"lines": [
{"accountId": "[[FOOD_DINING_ID="your Food & Dining account UUID"]]", "debitAmount": 50, "creditAmount": 0},
{"accountId": "[[CHECKING_ID="your Checking account UUID"]]", "debitAmount": 0, "creditAmount": 30}
]
}'
The API rejects the entry. You should see a message like "Entry not balanced: debits=50, credits=30" returned with an HTTP 400 status. That is exactly what you want. The balance validation caught the mismatch.
Why does this matter?
This is the fundamental guarantee of double-entry bookkeeping. Every dollar that enters one account must leave another. If the API accepted unbalanced entries, your ledger would drift out of balance and become unreliable. The validation is the single most important rule in the entire system.
Now submit a balanced entry. This time debits and credits both equal $50. What do you expect the API to return?
- Run this balanced entry (same UUIDs, but the credit is now $50):
curl -X POST http://localhost:8080/api/journal-entries \
-H "Content-Type: application/json" \
-d '{
"description": "Lunch",
"date": "2026-08-21",
"lines": [
{"accountId": "[[FOOD_DINING_ID="your Food & Dining account UUID"]]", "debitAmount": 50, "creditAmount": 0},
{"accountId": "[[CHECKING_ID="your Checking account UUID"]]", "debitAmount": 0, "creditAmount": 50}
]
}'
This time the API returns HTTP 201 with the saved journal entry. You should see the entry's generated UUID, description, date, and two line items. Each line includes the full account object and its debit/credit amounts.
- Confirm the entry was persisted by opening this URL in your browser:
http://localhost:8080/api/journal-entries
You should see a JSON array containing one journal entry with the description "Lunch" and two balanced line items.
Seeing a 500 error instead of 400?
- A 500 error means the exception is not being caught by the controller's try/catch. Check that the createEntry method in JournalEntryService throws IllegalArgumentException (not a different exception type).
- If you see "No value present" in the stack trace, the account UUID you pasted does not match any seeded account. Re-check the UUID from GET /api/accounts.
- If you see a JSON parse error, make sure the UUIDs in your curl command are wrapped in double quotes and formatted correctly.
Help me debug my journal entry API error.
✔️ Awesome, I've got everything!
Great. Double check that all seven files in your journal package are saved.
ⓧ I'd like to double check the full code
Your journal package should contain these seven files. Scroll up and confirm each one is present and matches what you pasted.
- JournalEntry.java with the @Entity annotation, UUID id, date, description, and @OneToMany lines list.
- JournalEntryLine.java with @ManyToOne references to both JournalEntry and Account, plus debitAmount and creditAmount as BigDecimal.
- JournalEntryRequest.java record with a nested LineRequest record.
- JournalEntryRepository.java extending JpaRepository<JournalEntry, UUID>.
- JournalEntryLineRepository.java extending JpaRepository<JournalEntryLine, UUID>.
- JournalEntryService.java with the createEntry method (balance validation) and findAll.
- JournalEntryController.java with POST /api/journal-entries and GET /api/journal-entries.
Your ledger now enforces the fundamental accounting rule: every transaction must balance. Up next, you will connect to Plaid's sandbox and pull real test bank transactions, so you do not have to create every journal entry by hand.
Secret mission
Build a Trial Balance Endpoint
Your ledger has accounts, journal entries, and Plaid transactions flowing in. But how do you know the books actually balance? Build the ultimate proof: a trial balance endpoint that aggregates every debit and credit across all accounts and tells you whether the fundamental accounting equation holds.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with a free Plaid sandbox account, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The Spring Boot application running on port 8080.
- The ledger-api project folder with all source code.
- The ledger database and its tables in your Docker PostgreSQL instance.
Keep everything running
No action needed. Choose this if you are still actively building or plan to add a React frontend to this API.
- Your Spring Boot app stays running on port 8080 and responds to API requests.
- Your PostgreSQL database retains all seeded accounts and journal entries.
- Your Plaid sandbox account remains active at no cost. Sandbox credentials do not expire.
Pause - I'll come back to this later
Shut down the running application to free up your terminal and port. Your files and database stay intact for when you return.
- Press Ctrl+C in the terminal where the Spring Boot app is running to stop it.
- Your ledger-api project folder and ledger database remain on your machine, ready to pick up where you left off.
- When you are ready to resume, open a terminal in the ledger-api folder and run mvn spring-boot:run to start the app again.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below in order.
- Press Ctrl+C in the terminal where the Spring Boot app is running to stop it.
- Drop the project database. In your IDE, open a new SQL console connected to the default postgres database (not ledger) and run the following command:
DROP DATABASE ledger;
- Delete the ledger-api project folder. In your terminal, navigate to the parent directory where you extracted the project and run:
macOS
rm -rf ledger-api
Windows
rmdir /s /q ledger-api
Nice Work!
Nice Work!
Nice work! You just built a production-style double-entry ledger API powered by real bank transaction data from the Plaid sandbox.
You've learned how to:
- Built a Spring Boot REST API backed by PostgreSQL that enforces double-entry bookkeeping rules, rejecting any journal entry where total debits do not equal total credits.
- Integrated with the Plaid sandbox API to pull test bank transactions from First Platypus Bank and automatically convert single-entry bank data into properly balanced double-entry journal entries.
- Organized your codebase using a package-by-feature structure that mirrors how enterprise Java projects are built in the real world.
- Completed an optional Secret Mission to push your skills even further.
Ready to quiz yourself?