Build PocketLedger Expense Tracker

Build a persistent Java CLI that records, totals, and reloads expenses.

Introduction

30 Second Summary

A quick expense note loses its value when closing the app wipes every entry. Reliable tracking means your spending history is waiting when you return.

In this project, you will build PocketLedger, a local command-line expense tracker that preserves categorized expenses across restarts. You will turn familiar Java syntax into a complete application you can run from IntelliJ IDEA.

What You'll Build

PocketLedger opens in IntelliJ IDEA with your saved expenses ready to list beside an exact decimal total.

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

  • Categorized expense entry with an immediate confirmation after each valid submission.
  • A persistent expense history that returns when you restart PocketLedger.
  • Input validation that rejects blank text plus impossible amounts without closing the menu.
  • Secret Mission: Add a category summary that totals only matching expenses regardless of letter case.

Are there any prerequisites?

You should be comfortable with basic Java syntax, loops, and methods.

Before We Start

This first step is your chance to commit to what PocketLedger does before the hands-on work begins. You will connect the app to someone who needs recorded expenses to remain available after a restart.

Set Up the Maven Project

PocketLedger needs a repeatable build before its menu grows across several classes. A standard Maven structure keeps compilation predictable.

In this step, you'll confirm your installed toolchain meets the project requirements. You'll create the Maven structure that runs one visible Java entry point.

In this step, get ready to:
  • Verify that the installed toolchain meets the required versions.
  • Create the pocketledger project with a standard Maven layout.
  • Build the starter App through Maven.
Verify the installed toolchain

The Maven version check reports Maven compatibility plus the Java runtime used for builds. PocketLedger needs Maven 3.6.3 or newer. It also needs Java 17 or newer.

  • Press the Super key to open the Linux application search.
  • Type IntelliJ IDEA into the search field.

You should see IntelliJ IDEA in the search results.

  • Press Enter to launch IntelliJ IDEA.

IntelliJ IDEA opens to its welcome screen or your most recent workspace.

  • Press Alt+F12 to open the Terminal tool window.
  • Check the installed Maven version plus its Java runtime by running:
mvn --version

✔️ Both versions meet the minimum

Continue when the output reports Maven 3.6.3 or newer. The Java version in the same output must report 17 or newer.

ⓧ One version is too old

Upgrade only the tool that falls below its minimum. Run the version check again after the upgrade.

  • Click File if the Java version is older than 17.
  • Select Project Structure.

The project structure settings show the JDK available to IntelliJ IDEA.

The current stable Maven release is 3.9.16. Its official installation page provides the Linux upgrade path.

  • Follow the Linux installation instructions for Maven 3.9.16.
  • Close the current Terminal tool window after finishing the upgrade.
  • Press Alt+F12 to start a new terminal session.
  • Recheck the installed versions by running:
mvn --version

Continue when both reported versions meet the minimum requirements.

ⓧ Command not found

The terminal cannot locate Maven. Install it through the official Linux instructions before creating PocketLedger.

  • Open the official Apache Maven installation page.
  • Follow the Linux installation path for Maven 3.9.16.
  • Return to IntelliJ IDEA after the installation completes.
  • Press Alt+F12 to start a new terminal session.
  • Confirm Maven is available by running:
mvn --version

You should now see the installed Maven version plus its Java runtime.

Good, your terminal now has a compatible Java and Maven toolchain. That gives every later build the same Java release target.

Still seeing an incompatible version?

Make sure you started a new terminal session after changing the project JDK. IntelliJ IDEA adds its configured project JDK to the environment for new terminal sessions.

Check which version appears in the complete mvn --version output. Ask for help with the exact lines that still look wrong.

Help me diagnose the Java or Maven version reported by IntelliJ IDEA.

Create the Maven project

A Maven project keeps source code under src/main/java. Its pom.xml file records the compiler settings used by every build.

  • Start the New Project wizard from the IntelliJ IDEA welcome screen or File menu.
  • Select Java from the project types.

The wizard now shows the settings for a Java project.

  • Select Maven under Build system.
  • Enter pocketledger in the project name field.

The project location now ends with the pocketledger directory.

  • Select OpenJDK 17 as the project JDK.

The wizard now shows OpenJDK 17 as the compiler runtime for this project.

  • Leave the sample code option unchecked.
  • Click Create.

That is the project shell in place. IntelliJ IDEA now shows the pocketledger file tree.

The first POM chunk defines the project identity plus its Java release. It remains a valid Maven file while you verify the base configuration.

  • Open pom.xml from the project file tree.
  • Replace its contents with this base configuration:
<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>

    <groupId>com.nextwork</groupId>
    <artifactId>pocketledger</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.release>17</maven.compiler.release>
    </properties>
</project>

What does this POM control?

  • The project coordinates identify this build as com.nextwork:pocketledger:1.0-SNAPSHOT.
  • The source encoding keeps text compilation consistent with UTF-8.
  • The compiler release makes Maven target Java 17.
  • Save pom.xml.
  • Confirm Maven can read the base POM by running:
mvn package

You should see BUILD SUCCESS near the end of the output. Maven has accepted the project coordinates plus the Java release setting.

Does Maven reject the POM?

Compare the opening <project> element with the closing </project> element. Check that each property also has a matching closing tag.

Help me find the XML problem in my PocketLedger POM.

The compiler plugin now makes the build tool explicit. Pinning version 3.15.0 keeps the compiler behavior repeatable.

  • Find the closing </properties> line in pom.xml.
  • Add this build configuration below that line:
    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.15.0</version>
                </plugin>
            </plugins>
        </pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

Why configure the compiler plugin?

The pluginManagement section pins Maven Compiler Plugin 3.15.0. The plugins section activates that compiler for PocketLedger.

The plugin reads the Java 17 release from the properties above. Your IDE build plus terminal build now target the same language release.

  • Save pom.xml.
  • Verify the pinned compiler configuration by running:
mvn package

You should see BUILD SUCCESS again. The project now builds through Maven Compiler Plugin 3.15.0.

Does the plugin build fail?

Check that the <build> block sits after </properties> but before </project>.

Help me troubleshoot the Maven compiler configuration in PocketLedger.

  • Press Alt+1 to open the Project tool window.
  • Select src/main/java in the project tree.

The selected source directory is where Maven looks for application classes.

  • Press Alt+Insert to open the new-item menu.
  • Select Package.

The package dialog is ready for PocketLedger's namespace.

  • Enter com.nextwork.pocketledger as the package name.
  • Press Enter to create the package.

You should see com.nextwork.pocketledger beneath src/main/java in the project tree.

Produce the first visible output

A Java application starts from a main(String[] args) method. This starter entry point proves that Maven can compile the package before the expense features arrive.

  • Select the com.nextwork.pocketledger package in the Project tool window.
  • Press Alt+Insert to open the new-item menu.

The menu now offers items that can be created inside the package.

  • Select Java Class.
  • Enter App as the class name.

The new-class dialog now shows App as the class to generate.

  • Press Enter to create App.java.
  • Replace the generated contents of App.java with this starter class:
package com.nextwork.pocketledger;

public class App {
    public static void main(String[] args) {
        System.out.println("PocketLedger is ready.");
    }
}

What does this code do?

  • The package declaration places App in the package created above.
  • The main(String[] args) method gives Java its application entry point.
  • The print statement gives you a visible signal that IntelliJ IDEA ran the correct class.
  • Save App.java.

Before you build, do you expect Maven to compile the package successfully?

  • Switch back to the Terminal tool window from earlier.
  • Build the starter application by running:
mvn package

You should see BUILD SUCCESS near the end of the output. Maven has compiled App.java for Java 17.

  • Click the green Run button beside main in App.java.

You should see PocketLedger is ready. in the IntelliJ IDEA console. You now have a runnable PocketLedger entry point.

Don't see the starter message?

Check that App.java sits inside src/main/java/com/nextwork/pocketledger. Its package declaration must match com.nextwork.pocketledger exactly.

Confirm the method is named main with the complete public static void main(String[] args) signature.

Help me debug why PocketLedger does not print its starter message.

✔️ Awesome, I've got everything!

Great. Save pom.xml plus App.java before moving on.

ⓧ I'd like to double check the full code

Compare both files with these complete versions.

<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>

    <groupId>com.nextwork</groupId>
    <artifactId>pocketledger</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.release>17</maven.compiler.release>
    </properties>

    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.15.0</version>
                </plugin>
            </plugins>
        </pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
package com.nextwork.pocketledger;

public class App {
    public static void main(String[] args) {
        System.out.println("PocketLedger is ready.");
    }
}

Your Maven foundation is ready. Next, you'll turn the starter class into an in-memory expense tracker with its own expense model.

Build an In-Memory Expense Tracker

Your Maven foundation now builds cleanly. PocketLedger needs a way to represent each purchase before it can become a useful tracker.

This step builds the first working version in memory. You will model expenses as objects.

A collection will keep those objects available to a menu. Keeping this version in memory lets you practise classes before file storage adds another moving part.

In this step, get ready to:
  • Model one expense with private fields plus getters.
  • Manage expenses through an ArrayList-backed List with an exact decimal total.
  • Run a menu that adds expenses during the current session.
Model one expense

An expense needs one object that keeps its description, amount, plus category together. The fields stay private so other classes read them through controlled methods.

  • In the project tree in IntelliJ IDEA, right-click the com.nextwork.pocketledger package.
  • Use the package context menu to create a Java class named Expense.
  • Replace the contents of Expense.java with this class:
package com.nextwork.pocketledger;

import java.math.BigDecimal;

public class Expense {
    private final String description;
    private final BigDecimal amount;
    private final String category;

    public Expense(String description, BigDecimal amount, String category) {
        this.description = description;
        this.amount = amount;
        this.category = category;
    }

    public String getDescription() {
        return description;
    }

    public BigDecimal getAmount() {
        return amount;
    }

    public String getCategory() {
        return category;
    }
}

What does this class do?

  • The private fields apply encapsulation by keeping each expense's data inside its object.
  • The constructor receives the three values required to create an expense.
  • The getters let the menu read each value without exposing the fields directly.
  • BigDecimal stores the amount as an exact decimal value.
  • Save Expense.java.
  • Compile the new expense model by running this command in the terminal from earlier:
mvn package

Why build now?

This build confirms that Java can compile the new class inside the existing package. A successful checkpoint keeps mistakes close to the code that caused them.

You should see BUILD SUCCESS near the end of the terminal output. Your first PocketLedger object now compiles.

Does the expense model fail to compile?

Check that Expense.java sits inside src/main/java/com/nextwork/pocketledger. Confirm that its package line matches that location.

Compare the capitalization of BigDecimal with its import. Java class names are case-sensitive.

Ask for help with the compiler output: help me fix the compilation problem in Expense.java

Manage a collection of expenses

PocketLedger needs one place to own every expense created during a session. ExpenseTracker wraps the collection so the menu does not manage list details itself.

  • Right-click the com.nextwork.pocketledger package in the project tree.
  • Use the package context menu to create a Java class named ExpenseTracker.
  • Replace the contents of ExpenseTracker.java with this class:
package com.nextwork.pocketledger;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

public class ExpenseTracker {
    private final List<Expense> expenses = new ArrayList<>();

    public void addExpense(Expense expense) {
        expenses.add(expense);
    }

    public List<Expense> getExpenses() {
        return List.copyOf(expenses);
    }

    public BigDecimal getTotal() {
        BigDecimal total = BigDecimal.ZERO;
        for (Expense expense : expenses) {
            total = total.add(expense.getAmount());
        }
        return total;
    }
}

How does the tracker work?

  • expenses uses the List<Expense> interface with an ArrayList implementation.
  • addExpense() places one expense into the collection.
  • getExpenses() returns a copy that callers can read safely.
  • getTotal() starts at BigDecimal.ZERO before adding each stored amount.
  • Save ExpenseTracker.java.
  • Compile the collection layer by running this command:
mvn package

What does this checkpoint prove?

The build checks the generic list types plus every BigDecimal operation. It also confirms that ExpenseTracker can use the Expense class from the same package.

You should see BUILD SUCCESS again. The in-memory collection plus total calculation now compile together.

Does the tracker fail to compile?

Check that both Java files use the same com.nextwork.pocketledger package. Confirm that Expense has each getter used by getTotal().

Check the imports for BigDecimal, ArrayList, plus List.

Ask for help with the two classes: help me debug ExpenseTracker.java

Make the tracker interactive

The model plus collection need a command-line boundary where you can enter values. One Scanner will read every menu choice from the same input stream.

You will add the menu in small compilable pieces. Each checkpoint keeps the feedback loop close to the latest method.

  • Select App.java in the project tree.
  • Add these imports immediately below the package declaration:
import java.math.BigDecimal;
import java.util.Scanner;

Why are these imports needed?

  • BigDecimal converts the amount entered as text into a decimal amount.
  • Scanner reads each line entered through the console.
  • Find the final closing brace in App.java.
  • Paste this menu method immediately above that brace:
    private static void printMenu() {
        System.out.println();
        System.out.println("PocketLedger");
        System.out.println("1. Add expense");
        System.out.println("2. List expenses");
        System.out.println("3. Show total");
        System.out.println("4. Exit");
        System.out.print("Choose an option: ");
    }

What does the menu method do?

printMenu() groups the four numbered options in one reusable method. The final line leaves the cursor beside the prompt so the learner's choice appears in the expected place.

  • Save App.java.
  • Compile the menu method by running this command:
mvn package

Why compile this method now?

This build catches syntax mistakes in the menu before input handling is added. You should see BUILD SUCCESS when the method is placed inside the App class.

Does the menu method cause a build failure?

Confirm that printMenu() appears before the final brace of the App class. A method placed outside the class cannot compile.

Ask for help with the method placement: help me place printMenu inside App.java

  • Paste this input method immediately below printMenu() inside App.java:
    private static void addExpense(Scanner scanner, ExpenseTracker tracker) {
        System.out.print("Description: ");
        String description = scanner.nextLine();
        System.out.print("Amount: ");
        String amountText = scanner.nextLine();
        System.out.print("Category: ");
        String category = scanner.nextLine();
        tracker.addExpense(new Expense(description, new BigDecimal(amountText), category));
        System.out.println("Expense added.");
    }

How is an expense added?

  • The method reads the description, amount text, plus category with the same scanner.
  • new BigDecimal(amountText) constructs the amount directly from the entered string.
  • new Expense(...) creates the object before addExpense() stores it.
  • Expense added. gives immediate confirmation after the collection changes.
  • Save App.java.
  • Compile the input method by running this command:
mvn package

What does this build check?

The compiler checks that App can construct an Expense with a decimal amount. It also checks the method parameters against Scanner plus ExpenseTracker.

You should see BUILD SUCCESS after Maven compiles the new input path.

Does the input method fail to compile?

Check that both imports remain below the package declaration. Confirm that addExpense() sits inside the App class.

Ask for help with the parameter types: help me fix the addExpense method

  • Paste this listing method immediately below addExpense() inside App.java:
    private static void showExpenses(ExpenseTracker tracker) {
        if (tracker.getExpenses().isEmpty()) {
            System.out.println("No expenses recorded.");
            return;
        }

        int number = 1;
        for (Expense expense : tracker.getExpenses()) {
            System.out.println(number + ". " + expense.getDescription() + " | "
                    + expense.getAmount().toPlainString() + " | " + expense.getCategory());
            number++;
        }
    }

How are expenses displayed?

  • isEmpty() handles a list with no recorded expenses.
  • The loop visits each expense returned by getExpenses().
  • toPlainString() displays the decimal amount without scientific notation.
  • number gives each displayed expense a position in the list.
  • Save App.java.
  • Compile the listing method by running this command:
mvn package

What does this build confirm?

The build checks the empty-list branch plus the enhanced loop. It also confirms that every getter in Expense matches the method calls in showExpenses().

You should see BUILD SUCCESS after the listing path compiles.

Does the listing method fail to compile?

Compare getDescription(), getAmount(), plus getCategory() with the getters in Expense.java.

Ask for help with the loop: help me debug showExpenses

  • Paste this total method immediately below showExpenses() inside App.java:
    private static void showTotal(ExpenseTracker tracker) {
        System.out.println("Total: " + tracker.getTotal().toPlainString());
    }

How is the total displayed?

showTotal() asks the tracker to perform the calculation. The menu only formats the returned decimal for the console.

  • Save App.java.
  • Compile the total path by running this command:
mvn package

Why check the total path separately?

This build verifies the connection between showTotal() plus getTotal(). You should see BUILD SUCCESS when their return types match.

Does the total method fail to compile?

Confirm that getTotal() returns a BigDecimal. Check that toPlainString() is called on that returned value.

Ask for help with the total connection: help me connect showTotal to ExpenseTracker

The helper methods now cover every menu action. The main method can connect them to one scanner plus a repeating loop.

  • Replace the current main() method in App.java with this first menu checkpoint:
    public static void main(String[] args) {
        ExpenseTracker tracker = new ExpenseTracker();

        try (Scanner scanner = new Scanner(System.in)) {
            boolean running = true;
            while (running) {
                printMenu();
                String choice = scanner.nextLine();
                running = false;
            }
        }
    }

What does this checkpoint do?

  • tracker holds the in-memory collection for this run.
  • The try-with-resources block creates one scanner for all console input.
  • printMenu() displays the menu before nextLine() waits for a choice.
  • running = false ends this first checkpoint after one choice.
  • Save App.java.

Before you run this checkpoint, what menu do you expect the console to display?

  • Click the green Run button beside main.
  • Enter 4 at the menu prompt.

You should see the PocketLedger heading plus four numbered options. The run ends after accepting the choice.

Does the menu fail to appear?

Check that the run configuration starts com.nextwork.pocketledger.App. Confirm that main() calls printMenu() before reading the choice.

Ask for help with the first menu run: help me debug the PocketLedger menu checkpoint

  • Find this temporary line inside the loop:
                running = false;

What does this line currently control?

The assignment stops the loop after its first input. Replacing it with the switch connects each choice to a real menu path.

  • Replace that line with this switch block:
                switch (choice) {
                    case "1":
                        addExpense(scanner, tracker);
                        break;
                    case "2":
                        showExpenses(tracker);
                        break;
                    case "3":
                        showTotal(tracker);
                        break;
                    case "4":
                        running = false;
                        System.out.println("Goodbye.");
                        break;
                    default:
                        System.out.println("Choose 1, 2, 3, or 4.");
                }

How does the switch complete the menu?

  • Choice 1 collects an expense before storing it in the tracker.
  • Choice 2 prints every expense in the current collection.
  • Choice 3 prints the exact decimal total.
  • Choice 4 changes running so the loop exits.
  • The default branch gives the valid choices after an unsupported input.
  • Save App.java.
  • Build the completed in-memory tracker by running this command:
mvn package

What does the final build prove?

Maven compiles the model, tracker, plus menu as one application. You should see BUILD SUCCESS before testing the four paths.

Does the completed menu fail to build?

Check that the switch sits inside the while loop immediately after choice is read. Compare each helper method name with its declaration.

Ask for help with the complete menu: help me fix the App.java menu loop

Before you run the completed tracker, do you think one expense will appear in both the list plus the total during the same session?

  • Click the green Run button beside main.
  • Enter 1 at the menu prompt.
  • Enter Lunch for the description.
  • Enter 12.50 for the amount.
  • Enter Food for the category.
  • Enter 2 at the next menu prompt.
  • Enter 3 at the next menu prompt.

You should see Expense added. followed by 1. Lunch | 12.50 | Food. The total path then prints Total: 12.50.

  • Enter 4 at the next menu prompt.

You should see Goodbye. before the program exits. That completes every valid menu path in one session.

Does the interactive test stop early?

A malformed amount can stop this version because input handling arrives in a later step. Repeat this checkpoint with the exact decimal 12.50.

If the menu does not repeat, confirm that running = false only remains inside case 4.

Ask for help with the runtime behavior: help me debug the PocketLedger menu flow

✔️ Awesome, I've got everything!

Great work. Save all three Java files before moving on.

ⓧ I'd like to double check the full code

Compare each project file with these exact versions.

pom.xml

<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>

    <groupId>com.nextwork</groupId>
    <artifactId>pocketledger</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.release>17</maven.compiler.release>
    </properties>

    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.15.0</version>
                </plugin>
            </plugins>
        </pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

What should this file contain?

The POM keeps the Java release plus compiler plugin configuration established in the previous step.

Expense.java

package com.nextwork.pocketledger;

import java.math.BigDecimal;

public class Expense {
    private final String description;
    private final BigDecimal amount;
    private final String category;

    public Expense(String description, BigDecimal amount, String category) {
        this.description = description;
        this.amount = amount;
        this.category = category;
    }

    public String getDescription() {
        return description;
    }

    public BigDecimal getAmount() {
        return amount;
    }

    public String getCategory() {
        return category;
    }
}

What should this class contain?

The expense model contains three private final fields. Its constructor plus getters provide the values used by the tracker.

ExpenseTracker.java

package com.nextwork.pocketledger;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

public class ExpenseTracker {
    private final List<Expense> expenses = new ArrayList<>();

    public void addExpense(Expense expense) {
        expenses.add(expense);
    }

    public List<Expense> getExpenses() {
        return List.copyOf(expenses);
    }

    public BigDecimal getTotal() {
        BigDecimal total = BigDecimal.ZERO;
        for (Expense expense : expenses) {
            total = total.add(expense.getAmount());
        }
        return total;
    }
}

What should this tracker contain?

The tracker owns the list. Its methods add expenses, return a safe copy, plus calculate the decimal total.

App.java

package com.nextwork.pocketledger;

import java.math.BigDecimal;
import java.util.Scanner;

public class App {
    public static void main(String[] args) {
        ExpenseTracker tracker = new ExpenseTracker();

        try (Scanner scanner = new Scanner(System.in)) {
            boolean running = true;
            while (running) {
                printMenu();
                String choice = scanner.nextLine();

                switch (choice) {
                    case "1":
                        addExpense(scanner, tracker);
                        break;
                    case "2":
                        showExpenses(tracker);
                        break;
                    case "3":
                        showTotal(tracker);
                        break;
                    case "4":
                        running = false;
                        System.out.println("Goodbye.");
                        break;
                    default:
                        System.out.println("Choose 1, 2, 3, or 4.");
                }
            }
        }
    }

    private static void printMenu() {
        System.out.println();
        System.out.println("PocketLedger");
        System.out.println("1. Add expense");
        System.out.println("2. List expenses");
        System.out.println("3. Show total");
        System.out.println("4. Exit");
        System.out.print("Choose an option: ");
    }

    private static void addExpense(Scanner scanner, ExpenseTracker tracker) {
        System.out.print("Description: ");
        String description = scanner.nextLine();
        System.out.print("Amount: ");
        String amountText = scanner.nextLine();
        System.out.print("Category: ");
        String category = scanner.nextLine();
        tracker.addExpense(new Expense(description, new BigDecimal(amountText), category));
        System.out.println("Expense added.");
    }

    private static void showExpenses(ExpenseTracker tracker) {
        if (tracker.getExpenses().isEmpty()) {
            System.out.println("No expenses recorded.");
            return;
        }

        int number = 1;
        for (Expense expense : tracker.getExpenses()) {
            System.out.println(number + ". " + expense.getDescription() + " | "
                    + expense.getAmount().toPlainString() + " | " + expense.getCategory());
            number++;
        }
    }

    private static void showTotal(ExpenseTracker tracker) {
        System.out.println("Total: " + tracker.getTotal().toPlainString());
    }
}

What should the app contain?

The app creates one tracker plus one scanner. Its loop routes each numbered choice to the corresponding helper method.

You have turned the starter message into a working expense tracker. PocketLedger can now add, list, plus total valid expenses during one run.

The collection currently lives only inside the running program. Next, you will expose that limitation before saving expenses to a local file.

Save Expenses to a Local File

Your tracker can hold expenses only while its current process is alive. A restart wipes the list, which makes the app unreliable for real records.

This step adds file I/O so PocketLedger can write each expense to expenses.txt. Startup loading stays absent so you can see the incomplete persistence loop before fixing it.

In this step, get ready to:
  • Reproduce the restart problem with the current in-memory tracker.
  • Convert expenses into pipe-delimited records and write them to a local file.
  • Save the complete expense list after each valid addition.
Reproduce the restart gap

The ArrayList inside ExpenseTracker belongs to one running process. Closing that process removes every object stored in the collection.

  • In IntelliJ IDEA, click the green Run button beside main.
  • Enter 1 at the menu prompt.
  • Enter Lunch for the description.
  • Enter 12.50 for the amount.
  • Enter Food for the category.

You should see Expense added. in the console. The new object now lives in the current tracker.

  • Enter 4 to exit PocketLedger.

Before you restart PocketLedger, do you expect Lunch to survive when no file stores it?

  • Click the green Run button beside main again.
  • Enter 2 to list the expenses.

You should see No expenses recorded. The empty list confirms that the previous expense existed only in memory.

Why reproduce the limitation?

This result gives file persistence a clear job. PocketLedger needs to move expense data beyond the lifetime of one Java process.

Serialize and write expenses

A text file stores characters instead of Java objects. Each Expense therefore needs a stable text representation that can be written now and parsed later.

  • Select src/main/java/com/nextwork/pocketledger/Expense.java in the Project tool window.
  • Place your cursor before the final closing brace in Expense.java.
  • Add the storage conversion methods by copying this code:
    public String toStorageLine() {
        return description + "|" + amount.toPlainString() + "|" + category;
    }

    public static Expense fromStorageLine(String line) {
        String[] parts = line.split("\\|", -1);
        if (parts.length != 3) {
            throw new IllegalArgumentException("Saved expense must have three fields.");
        }
        return new Expense(parts[0], new BigDecimal(parts[1]), parts[2]);
    }

What do these methods do?

  • The toStorageLine() method joins the description, decimal amount, and category into one pipe-delimited record.
  • The toPlainString() method keeps the amount in a readable decimal form.
  • The fromStorageLine() method splits a stored record into exactly three fields.
  • The field-count check rejects a record that cannot recreate one expense.
  • Save Expense.java.
  • Return to IntelliJ IDEA's terminal.
  • Compile the storage methods by running:
mvn package

What does this build prove?

Maven compiles the new methods against Java 17. A successful build confirms that the storage format can be added without breaking the existing tracker.

You should see BUILD SUCCESS near the end of the terminal output.

Seeing a compilation error in Expense?

Check that both methods sit inside the Expense class. Make sure the final class brace remains after fromStorageLine().

Check that the split pattern contains two backslashes before the pipe inside the Java string.

Help me fix the storage methods in Expense.java.

The expense now knows how to become one line of text. A dedicated file store can collect those lines and write them to disk.

  • Press Alt+1 to focus the Project tool window.
  • Select the com.nextwork.pocketledger package under src/main/java.
  • Press Alt+Insert to open the new-item menu.
  • Select Java Class.
  • Enter ExpenseFileStore as the class name.
  • Press Enter to create the class.
  • Replace the generated contents of ExpenseFileStore.java with this code:
package com.nextwork.pocketledger;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public class ExpenseFileStore {
    private final Path dataFile;

    public ExpenseFileStore(Path dataFile) {
        this.dataFile = dataFile;
    }

    public void save(List<Expense> expenses) throws IOException {
        List<String> lines = new ArrayList<>();
        for (Expense expense : expenses) {
            lines.add(expense.toStorageLine());
        }
        Files.write(dataFile, lines);
    }
}

What does the file store do?

  • The dataFile field holds the destination path supplied by App.
  • The save() method converts every expense into one storage line.
  • The Files.write() call creates a missing file or replaces the existing file contents.
  • The IOException declaration makes callers handle a failed file operation.
  • Save ExpenseFileStore.java.
  • Return to IntelliJ IDEA's terminal.
  • Compile the new file store by running:
mvn package

What does this build prove?

Maven compiles the new class with the existing expense model. A successful result confirms that the file store can receive a list of expenses and write its text records.

You should see BUILD SUCCESS in the terminal.

File store not compiling?

Confirm that ExpenseFileStore.java sits inside the com.nextwork.pocketledger package. Check each import against the code above.

Help me fix ExpenseFileStore.java.

Save after every valid addition

The file store now exists without a caller. App needs one shared path plus one store instance so every successful addition can replace the saved records.

  • Select src/main/java/com/nextwork/pocketledger/App.java in the Project tool window.
  • Replace the current import block with this import group:
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.util.Scanner;

Why add these imports?

The Path import gives the app a file location. The IOException import lets the menu report a write failure without terminating.

  • Save App.java.
  • Return to IntelliJ IDEA's terminal.
  • Confirm the import group compiles by running:
mvn package

What does this build prove?

This build checks that Java can resolve the file path and I/O exception types. The unused imports are valid while you connect them in the next edit.

You should see BUILD SUCCESS in the terminal.

Imports not resolving?

Confirm that the imports appear below the package declaration. Check that each imported type uses the exact package shown above.

Help me fix the imports in App.java.

  • Return to App.java.
  • Add the data-file field directly below the App class declaration:
    private static final Path DATA_FILE = Path.of("expenses.txt");

What does the data-file field hold?

The DATA_FILE field points to expenses.txt in the app's working directory. IntelliJ IDEA runs PocketLedger from the Maven project directory.

  • Add the store declaration directly below ExpenseTracker tracker = new ExpenseTracker(); inside main().
        ExpenseFileStore store = new ExpenseFileStore(DATA_FILE);

Why create one store?

The menu reuses one ExpenseFileStore for the whole session. Every save therefore targets the same expenses.txt path.

  • Save App.java.
  • Return to IntelliJ IDEA's terminal.
  • Confirm the store setup compiles by running:
mvn package

What does this build prove?

This build checks the connection between App and ExpenseFileStore. PocketLedger still behaves as before until the add path calls the store.

You should see BUILD SUCCESS in the terminal.

Store setup not compiling?

Confirm that DATA_FILE sits inside the App class. Confirm that the store declaration sits inside main().

Help me fix the store setup in App.java.

The final connection passes the store into the add path. That method saves the full tracker list after adding the new object.

  • Find the existing addExpense(scanner, tracker); call inside menu choice 1.
  • Replace that call with this line:
                        addExpense(scanner, tracker, store);

Why pass the store here?

The add path already owns the newly created expense. Passing store gives that path everything it needs to update the file immediately.

  • Find the existing addExpense() method in App.java.
  • Replace the entire method with this version:
    private static void addExpense(Scanner scanner, ExpenseTracker tracker, ExpenseFileStore store) {
        System.out.print("Description: ");
        String description = scanner.nextLine();
        System.out.print("Amount: ");
        String amountText = scanner.nextLine();
        System.out.print("Category: ");
        String category = scanner.nextLine();
        Expense expense = new Expense(description, new BigDecimal(amountText), category);
        tracker.addExpense(expense);
        try {
            store.save(tracker.getExpenses());
            System.out.println("Expense saved.");
        } catch (IOException exception) {
            System.out.println("Expense added in memory but could not be saved: " + exception.getMessage());
        }
    }

What does the updated add path do?

  • The method creates one Expense from the three answers.
  • The tracker receives the expense before the file operation begins.
  • The store replaces expenses.txt with the tracker's complete current list.
  • The separate IOException handler reports a failed save while retaining the in-memory expense.
  • Save App.java.
  • Return to IntelliJ IDEA's terminal.
  • Compile the completed save path by running:
mvn package

What does this build prove?

Maven now compiles the model, tracker, file store, and menu as one application. A successful build confirms that the save path is connected.

You should see BUILD SUCCESS near the end of the output.

Save path not compiling?

Check that the menu call and the addExpense() signature both include store. Confirm that the save call sits inside the method's try block.

Help me fix the save path in App.java.

Before you run PocketLedger, what do you expect to appear beside pom.xml after the first expense is saved?

  • Click the green Run button beside main.
  • Enter 1 at the menu prompt.
  • Enter Lunch for the description.
  • Enter 12.50 for the amount.
  • Enter Food for the category.

You should see Expense saved. The app has written the tracker list to disk.

  • Enter 4 to exit PocketLedger.
  • Select expenses.txt beside pom.xml in the Project tool window.

You should see Lunch|12.50|Food inside the file. The three values now survive outside the Java process.

Before you run the app again, do you think the menu can restore that saved line without calling load()?

  • Click the green Run button beside main again.
  • Enter 2 to list the expenses.

You should still see No expenses recorded. The file is durable, but the restarted tracker never reads it.

What has this proved?

Saving and loading are separate halves of persistence. PocketLedger now writes durable records, while startup still creates an empty tracker.

Don't see expenses.txt?

Confirm that the console printed Expense saved. after your valid entry. Check the project directory beside pom.xml because the relative path writes the file there.

Help me find why expenses.txt was not created.

✔️ Awesome, I've got everything!

Your project builds successfully. Confirm that expenses.txt contains the expense you saved.

ⓧ I'd like to double check the full code

Compare each project file with the cumulative code below.

pom.xml

<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>

    <groupId>com.nextwork</groupId>
    <artifactId>pocketledger</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.release>17</maven.compiler.release>
    </properties>

    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.15.0</version>
                </plugin>
            </plugins>
        </pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

src/main/java/com/nextwork/pocketledger/App.java

package com.nextwork.pocketledger;

import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.util.Scanner;

public class App {
    private static final Path DATA_FILE = Path.of("expenses.txt");

    public static void main(String[] args) {
        ExpenseTracker tracker = new ExpenseTracker();
        ExpenseFileStore store = new ExpenseFileStore(DATA_FILE);

        try (Scanner scanner = new Scanner(System.in)) {
            boolean running = true;
            while (running) {
                printMenu();
                String choice = scanner.nextLine();

                switch (choice) {
                    case "1":
                        addExpense(scanner, tracker, store);
                        break;
                    case "2":
                        showExpenses(tracker);
                        break;
                    case "3":
                        showTotal(tracker);
                        break;
                    case "4":
                        running = false;
                        System.out.println("Goodbye.");
                        break;
                    default:
                        System.out.println("Choose 1, 2, 3, or 4.");
                }
            }
        }
    }

    private static void printMenu() {
        System.out.println();
        System.out.println("PocketLedger");
        System.out.println("1. Add expense");
        System.out.println("2. List expenses");
        System.out.println("3. Show total");
        System.out.println("4. Exit");
        System.out.print("Choose an option: ");
    }

    private static void addExpense(Scanner scanner, ExpenseTracker tracker, ExpenseFileStore store) {
        System.out.print("Description: ");
        String description = scanner.nextLine();
        System.out.print("Amount: ");
        String amountText = scanner.nextLine();
        System.out.print("Category: ");
        String category = scanner.nextLine();
        Expense expense = new Expense(description, new BigDecimal(amountText), category);
        tracker.addExpense(expense);
        try {
            store.save(tracker.getExpenses());
            System.out.println("Expense saved.");
        } catch (IOException exception) {
            System.out.println("Expense added in memory but could not be saved: " + exception.getMessage());
        }
    }

    private static void showExpenses(ExpenseTracker tracker) {
        if (tracker.getExpenses().isEmpty()) {
            System.out.println("No expenses recorded.");
            return;
        }

        int number = 1;
        for (Expense expense : tracker.getExpenses()) {
            System.out.println(number + ". " + expense.getDescription() + " | "
                    + expense.getAmount().toPlainString() + " | " + expense.getCategory());
            number++;
        }
    }

    private static void showTotal(ExpenseTracker tracker) {
        System.out.println("Total: " + tracker.getTotal().toPlainString());
    }
}

src/main/java/com/nextwork/pocketledger/Expense.java

package com.nextwork.pocketledger;

import java.math.BigDecimal;

public class Expense {
    private final String description;
    private final BigDecimal amount;
    private final String category;

    public Expense(String description, BigDecimal amount, String category) {
        this.description = description;
        this.amount = amount;
        this.category = category;
    }

    public String getDescription() {
        return description;
    }

    public BigDecimal getAmount() {
        return amount;
    }

    public String getCategory() {
        return category;
    }

    public String toStorageLine() {
        return description + "|" + amount.toPlainString() + "|" + category;
    }

    public static Expense fromStorageLine(String line) {
        String[] parts = line.split("\\|", -1);
        if (parts.length != 3) {
            throw new IllegalArgumentException("Saved expense must have three fields.");
        }
        return new Expense(parts[0], new BigDecimal(parts[1]), parts[2]);
    }
}

src/main/java/com/nextwork/pocketledger/ExpenseFileStore.java

package com.nextwork.pocketledger;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public class ExpenseFileStore {
    private final Path dataFile;

    public ExpenseFileStore(Path dataFile) {
        this.dataFile = dataFile;
    }

    public void save(List<Expense> expenses) throws IOException {
        List<String> lines = new ArrayList<>();
        for (Expense expense : expenses) {
            lines.add(expense.toStorageLine());
        }
        Files.write(dataFile, lines);
    }
}

src/main/java/com/nextwork/pocketledger/ExpenseTracker.java

package com.nextwork.pocketledger;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

public class ExpenseTracker {
    private final List<Expense> expenses = new ArrayList<>();

    public void addExpense(Expense expense) {
        expenses.add(expense);
    }

    public List<Expense> getExpenses() {
        return List.copyOf(expenses);
    }

    public BigDecimal getTotal() {
        BigDecimal total = BigDecimal.ZERO;
        for (Expense expense : expenses) {
            total = total.add(expense.getAmount());
        }
        return total;
    }
}

You have made PocketLedger write durable expense records while preserving its in-memory behavior after a failed save. Next, you will load expenses.txt during startup so the saved entries return to the tracker.

Load Expenses at Startup

Your app can write every expense to expenses.txt, but each new process still starts with an empty ExpenseTracker. The saved records exist without reaching the menu.

Startup loading completes the file I/O loop. PocketLedger will rebuild its collection before asking for the first menu choice.

In this step, get ready to:
  • Review how fromStorageLine(String line) reconstructs one expense.
  • Add load() to read valid records from expenses.txt.
  • Hydrate the tracker during startup and prove that an expense survives a restart.
Review the saved-record parser

Each saved line must become an Expense before the tracker can use it. Your existing parser already enforces the three-field storage format.

  • In the IntelliJ IDEA Project tool window, return to src/main/java/com/nextwork/pocketledger/Expense.java.
  • Locate fromStorageLine(String line) inside the Expense class.
  • Trace how split("\\|", -1) creates the three fields used to construct an expense.

How Does the Parser Rebuild an Expense?

The parser separates the description, amount, and category. It rejects a line when the field count differs from three.

The BigDecimal(String) constructor recreates the amount from its stored decimal text. The returned Expense is ready to enter the tracker.

Load expenses from the file

The file store needs a loading path that mirrors its saving path. It should also protect valid expenses when another saved line is malformed.

  • Switch back to src/main/java/com/nextwork/pocketledger/ExpenseFileStore.java in the Project tool window.
  • Find the closing brace for save(List<Expense> expenses).
  • Add the loading behavior below save(List<Expense> expenses) by pasting this method:
    public List<Expense> load() throws IOException {
        List<Expense> expenses = new ArrayList<>();
        if (!Files.exists(dataFile)) {
            return expenses;
        }

        for (String line : Files.readAllLines(dataFile)) {
            if (line.isBlank()) {
                continue;
            }
            try {
                expenses.add(Expense.fromStorageLine(line));
            } catch (IllegalArgumentException exception) {
                System.out.println("Skipping invalid saved expense: " + line);
            }
        }
        return expenses;
    }

What Does This Code Do?

  • The new list collects every expense that loads successfully.
  • The existence check returns an empty list when expenses.txt has not been created.
  • Files.readAllLines reads the saved records so the loop can parse each nonblank line.
  • The inner exception handler skips one malformed record while preserving every valid record.
  • Save ExpenseFileStore.java.
  • Switch to IntelliJ IDEA's terminal from earlier.
  • Compile the project by running this command:
mvn package

What Does This Check Prove?

The package command compiles the new load() method with the rest of PocketLedger. A successful build confirms that the method uses compatible types and handles its checked file exception.

You should see BUILD SUCCESS near the end of the terminal output.

Build Not Succeeding?

Check that load() sits inside the ExpenseFileStore class. Confirm that the class still has its final closing brace.

Compare the method's braces with the reference below if the compiler points to the end of the file.

Help me fix the compilation problem in ExpenseFileStore.java.

Hydrate the tracker before the menu

Loading belongs at startup because the menu reads directly from the tracker. Each restored expense must enter that tracker before the scanner loop begins.

  • Return to src/main/java/com/nextwork/pocketledger/App.java in the Project tool window.
  • Find ExpenseFileStore store = new ExpenseFileStore(DATA_FILE); inside main(String[] args).
  • Add the startup-loading block directly below that line by pasting:
        try {
            for (Expense expense : store.load()) {
                tracker.addExpense(expense);
            }
        } catch (IOException exception) {
            System.out.println("Saved expenses could not be loaded: " + exception.getMessage());
        }

What Does This Code Do?

  • The enhanced loop receives each expense returned by store.load().
  • tracker.addExpense(expense) rebuilds the in-memory collection one record at a time.
  • The outer exception handler reports a file-reading failure while allowing the menu to open with an empty collection.
  • Save App.java.
  • Click the green Run button beside main.
  • Enter 1 at the menu.
  • Enter Train for the description.
  • Enter 5.25 for the amount.
  • Enter Travel for the category.
  • Enter 4 at the next menu to exit PocketLedger.

You should see Expense saved. after the addition. PocketLedger then prints Goodbye. when the process ends.

Before you start PocketLedger again, do you expect the new process to recover Train from the file?

  • Click the green Run button beside main again.
  • Enter 2 to list the loaded expenses.
  • Enter 3 to display the loaded total.
  • Enter 4 to exit PocketLedger.

You should see a list entry containing Train | 5.25 | Travel. The total includes 5.25 alongside any expenses you saved earlier.

Expense Missing After Restart?

Confirm that expenses.txt remains in the PocketLedger project directory. The app reads the file relative to its working directory.

Check that the store.load() loop appears before the scanner loop in App.java.

Help me find why my saved expense does not load after restart.

✔️ Awesome, I've got everything!

Great work. Save both changed files before moving on.

ⓧ I'd like to double check the full code

Compare the complete versions of the two files changed in this step.

ExpenseFileStore.java.

package com.nextwork.pocketledger;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public class ExpenseFileStore {
    private final Path dataFile;

    public ExpenseFileStore(Path dataFile) {
        this.dataFile = dataFile;
    }

    public void save(List<Expense> expenses) throws IOException {
        List<String> lines = new ArrayList<>();
        for (Expense expense : expenses) {
            lines.add(expense.toStorageLine());
        }
        Files.write(dataFile, lines);
    }

    public List<Expense> load() throws IOException {
        List<Expense> expenses = new ArrayList<>();
        if (!Files.exists(dataFile)) {
            return expenses;
        }

        for (String line : Files.readAllLines(dataFile)) {
            if (line.isBlank()) {
                continue;
            }
            try {
                expenses.add(Expense.fromStorageLine(line));
            } catch (IllegalArgumentException exception) {
                System.out.println("Skipping invalid saved expense: " + line);
            }
        }
        return expenses;
    }
}

What Should You Compare?

The complete file keeps save() unchanged. The new load() method handles missing files, blank lines, valid records, and malformed records.

App.java.

package com.nextwork.pocketledger;

import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.util.Scanner;

public class App {
    private static final Path DATA_FILE = Path.of("expenses.txt");

    public static void main(String[] args) {
        ExpenseTracker tracker = new ExpenseTracker();
        ExpenseFileStore store = new ExpenseFileStore(DATA_FILE);

        try {
            for (Expense expense : store.load()) {
                tracker.addExpense(expense);
            }
        } catch (IOException exception) {
            System.out.println("Saved expenses could not be loaded: " + exception.getMessage());
        }

        try (Scanner scanner = new Scanner(System.in)) {
            boolean running = true;
            while (running) {
                printMenu();
                String choice = scanner.nextLine();

                switch (choice) {
                    case "1":
                        addExpense(scanner, tracker, store);
                        break;
                    case "2":
                        showExpenses(tracker);
                        break;
                    case "3":
                        showTotal(tracker);
                        break;
                    case "4":
                        running = false;
                        System.out.println("Goodbye.");
                        break;
                    default:
                        System.out.println("Choose 1, 2, 3, or 4.");
                }
            }
        }
    }

    private static void printMenu() {
        System.out.println();
        System.out.println("PocketLedger");
        System.out.println("1. Add expense");
        System.out.println("2. List expenses");
        System.out.println("3. Show total");
        System.out.println("4. Exit");
        System.out.print("Choose an option: ");
    }

    private static void addExpense(
            Scanner scanner,
            ExpenseTracker tracker,
            ExpenseFileStore store) {
        System.out.print("Description: ");
        String description = scanner.nextLine();
        System.out.print("Amount: ");
        String amountText = scanner.nextLine();
        System.out.print("Category: ");
        String category = scanner.nextLine();

        Expense expense = new Expense(description, new BigDecimal(amountText), category);
        tracker.addExpense(expense);
        try {
            store.save(tracker.getExpenses());
            System.out.println("Expense saved.");
        } catch (IOException exception) {
            System.out.println("Expense added in memory but could not be saved: " + exception.getMessage());
        }
    }

    private static void showExpenses(ExpenseTracker tracker) {
        if (tracker.getExpenses().isEmpty()) {
            System.out.println("No expenses recorded.");
            return;
        }

        int number = 1;
        for (Expense expense : tracker.getExpenses()) {
            System.out.println(
                    number + ". "
                            + expense.getDescription() + " | "
                            + expense.getAmount().toPlainString() + " | "
                            + expense.getCategory());
            number++;
        }
    }

    private static void showTotal(ExpenseTracker tracker) {
        System.out.println("Total: " + tracker.getTotal().toPlainString());
    }
}

What Should You Compare?

The startup-loading block sits after the tracker and store are created. It finishes before the scanner loop displays the first menu.

You have closed the persistence loop. Next, you will stop invalid amounts and impossible expenses from crashing the menu.

Validate Expense Input

PocketLedger now reloads saved expenses after every restart. Bad descriptions or impossible amounts can still enter the model.

In this step, you will add exception handling that rejects invalid expenses. The menu will remain available for the next attempt.

In this step, get ready to:
  • Protect each expense from blank text or reserved pipe characters.
  • Reject nonnumeric or nonpositive amounts without stopping the menu.
  • Confirm the Maven build succeeds after the changes.
Protect the expense model

The Expense constructor controls which values become expense objects. Constructor validation keeps invalid data out of the tracker before anything can be saved.

  • In IntelliJ IDEA, select Expense.java under src/main/java/com/nextwork/pocketledger.
  • Inside the Expense() constructor, add these checks before this.description = description;:
if (description == null || description.isBlank()) {
    throw new IllegalArgumentException("Description cannot be blank.");
}
if (category == null || category.isBlank()) {
    throw new IllegalArgumentException("Category cannot be blank.");
}
if (description.contains("|") || category.contains("|")) {
    throw new IllegalArgumentException("Description and category cannot contain |.");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
    throw new IllegalArgumentException("Amount must be greater than zero.");
}

What does this validation do?

  • The first two checks reject missing or blank descriptions and categories.
  • The pipe check protects the three-field format used by expenses.txt.
  • The amount check rejects missing values or values at or below BigDecimal.ZERO.
  • Each check runs before the fields are assigned. An invalid expense never reaches the tracker.
  • Save Expense.java.
  • Press Alt+F12 to return to the Terminal tool window.
  • Check the updated model by running this build command:
mvn package

What does this build check prove?

The build compiles the new constructor checks against Java 17. A successful result confirms that the validation syntax fits the existing project.

You should see BUILD SUCCESS near the end of the output.

Build failing after the constructor checks?

Confirm that every check sits inside the constructor before the three field assignments. Check that each opening brace has a matching closing brace.

Help me fix the constructor validation in Expense.java.

Handle user mistakes at the boundary

The constructor now reports invalid values with an IllegalArgumentException. The menu needs to catch that exception so the existing loop can continue.

  • In IntelliJ IDEA, select App.java under src/main/java/com/nextwork/pocketledger.
  • Find the existing addExpense() method.
  • Replace the entire method with this version:
private static void addExpense(
        Scanner scanner,
        ExpenseTracker tracker,
        ExpenseFileStore store) {
    System.out.print("Description: ");
    String description = scanner.nextLine();
    System.out.print("Amount: ");
    String amountText = scanner.nextLine();
    System.out.print("Category: ");
    String category = scanner.nextLine();

    try {
        Expense expense = new Expense(description, new BigDecimal(amountText), category);
        tracker.addExpense(expense);
        try {
            store.save(tracker.getExpenses());
            System.out.println("Expense saved.");
        } catch (IOException exception) {
            System.out.println("Expense added in memory but could not be saved: " + exception.getMessage());
        }
    } catch (IllegalArgumentException exception) {
        System.out.println("Expense not added: " + exception.getMessage());
    }
}

How does the menu stay available?

  • The outer try block covers amount conversion and expense construction.
  • The tracker receives the expense only after those operations succeed.
  • The nested IOException handler remains responsible for file save failures.
  • The outer catch block prints an input-specific message. Control then returns to the existing menu loop.
  • Save App.java.

Before you run PocketLedger, do you expect a nonnumeric amount to stop the process or return you to the menu?

  • Click the green Run button beside main.
  • Attempt to add Bad amount with amount abc in category Food.

You should see a message beginning with Expense not added:. The numbered menu should appear again in the same console.

  • Attempt to add Refund with amount -4 in category Other.

You should see Expense not added: Amount must be greater than zero. followed by the menu. PocketLedger has survived both invalid attempts.

Does the app still stop?

Confirm that the outer catch appears after the complete input-handling try block. Check that tracker.addExpense(expense); remains inside that block.

Help me find why invalid input still stops PocketLedger.

Recheck the complete behavior

Input validation must preserve every valid workflow. This final check proves that valid expenses still save while rejected attempts leave the tracker unchanged.

  • Add Lunch with amount 12.50 in category Food.

You should see Expense saved. before the menu returns.

  • Add Train with amount 5.25 in category Travel.
  • Choose option 3 to display the total.

The total should include both valid additions. Any expenses loaded when the app started also remain part of the total.

Now challenge each validation rule while the app is still running.

  • Attempt to add an expense using a blank description with amount 1.00 in category Food.
  • Attempt to add Coffee|Snack with amount 3.50 in category Food.
  • Attempt to add Snack with amount abc in category Food.
  • Attempt to add Adjustment with amount -4 in category Other.

PocketLedger should reject every attempt. The two valid additions should remain in the tracker.

Before you restart PocketLedger, which entries do you expect the list to show?

  • Choose option 4 to exit PocketLedger.
  • Click the green Run button beside main.
  • Choose option 2 to list the reloaded expenses.
  • Choose option 3 to display the reloaded total.

You should see the valid Lunch and Train entries. The blank or malformed attempts should be absent from the list and total.

Did an invalid expense reappear?

Confirm that tracker.addExpense(expense); runs after expense construction succeeds. Invalid input must reach the outer catch before the tracker or file store receives it.

Help me trace how an invalid expense reached expenses.txt.

✔️ Awesome, I've got everything!

Great. Your project builds successfully. Save every open Java file before moving on.

ⓧ I'd like to double check the full code

Compare each project file with the complete versions below.

pom.xml

<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>

    <groupId>com.nextwork</groupId>
    <artifactId>pocketledger</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.release>17</maven.compiler.release>
    </properties>

    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.15.0</version>
                </plugin>
            </plugins>
        </pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

What to compare in pom.xml

This file keeps the Java 17 release target and the compiler plugin configuration unchanged.

src/main/java/com/nextwork/pocketledger/Expense.java

package com.nextwork.pocketledger;

import java.math.BigDecimal;

public class Expense {
    private final String description;
    private final BigDecimal amount;
    private final String category;

    public Expense(String description, BigDecimal amount, String category) {
        if (description == null || description.isBlank()) {
            throw new IllegalArgumentException("Description cannot be blank.");
        }
        if (category == null || category.isBlank()) {
            throw new IllegalArgumentException("Category cannot be blank.");
        }
        if (description.contains("|") || category.contains("|")) {
            throw new IllegalArgumentException("Description and category cannot contain |.");
        }
        if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Amount must be greater than zero.");
        }

        this.description = description;
        this.amount = amount;
        this.category = category;
    }

    public String getDescription() {
        return description;
    }

    public BigDecimal getAmount() {
        return amount;
    }

    public String getCategory() {
        return category;
    }

    public String toStorageLine() {
        return description + "|" + amount.toPlainString() + "|" + category;
    }

    public static Expense fromStorageLine(String line) {
        String[] parts = line.split("\\|", -1);
        if (parts.length != 3) {
            throw new IllegalArgumentException("Saved expense must have three fields.");
        }
        return new Expense(parts[0], new BigDecimal(parts[1]), parts[2]);
    }
}

What to compare in Expense.java

The constructor validates every field before assigning it. The storage methods remain unchanged.

src/main/java/com/nextwork/pocketledger/ExpenseTracker.java

package com.nextwork.pocketledger;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

public class ExpenseTracker {
    private final List<Expense> expenses = new ArrayList<>();

    public void addExpense(Expense expense) {
        expenses.add(expense);
    }

    public List<Expense> getExpenses() {
        return List.copyOf(expenses);
    }

    public BigDecimal getTotal() {
        BigDecimal total = BigDecimal.ZERO;
        for (Expense expense : expenses) {
            total = total.add(expense.getAmount());
        }
        return total;
    }
}

What to compare in ExpenseTracker.java

The tracker still stores accepted expenses and calculates their total. Validation happens before an expense reaches this class.

src/main/java/com/nextwork/pocketledger/ExpenseFileStore.java

package com.nextwork.pocketledger;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public class ExpenseFileStore {
    private final Path dataFile;

    public ExpenseFileStore(Path dataFile) {
        this.dataFile = dataFile;
    }

    public void save(List<Expense> expenses) throws IOException {
        List<String> lines = new ArrayList<>();
        for (Expense expense : expenses) {
            lines.add(expense.toStorageLine());
        }
        Files.write(dataFile, lines);
    }

    public List<Expense> load() throws IOException {
        List<Expense> expenses = new ArrayList<>();
        if (!Files.exists(dataFile)) {
            return expenses;
        }

        for (String line : Files.readAllLines(dataFile)) {
            if (line.isBlank()) {
                continue;
            }
            try {
                expenses.add(Expense.fromStorageLine(line));
            } catch (IllegalArgumentException exception) {
                System.out.println("Skipping invalid saved expense: " + line);
            }
        }
        return expenses;
    }
}

What to compare in ExpenseFileStore.java

The file store still writes the tracker contents and skips malformed records during loading.

src/main/java/com/nextwork/pocketledger/App.java

package com.nextwork.pocketledger;

import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.util.Scanner;

public class App {
    private static final Path DATA_FILE = Path.of("expenses.txt");

    public static void main(String[] args) {
        ExpenseTracker tracker = new ExpenseTracker();
        ExpenseFileStore store = new ExpenseFileStore(DATA_FILE);

        try {
            for (Expense expense : store.load()) {
                tracker.addExpense(expense);
            }
        } catch (IOException exception) {
            System.out.println("Saved expenses could not be loaded: " + exception.getMessage());
        }

        try (Scanner scanner = new Scanner(System.in)) {
            boolean running = true;
            while (running) {
                printMenu();
                String choice = scanner.nextLine();

                switch (choice) {
                    case "1":
                        addExpense(scanner, tracker, store);
                        break;
                    case "2":
                        showExpenses(tracker);
                        break;
                    case "3":
                        showTotal(tracker);
                        break;
                    case "4":
                        running = false;
                        System.out.println("Goodbye.");
                        break;
                    default:
                        System.out.println("Choose 1, 2, 3, or 4.");
                }
            }
        }
    }

    private static void printMenu() {
        System.out.println();
        System.out.println("PocketLedger");
        System.out.println("1. Add expense");
        System.out.println("2. List expenses");
        System.out.println("3. Show total");
        System.out.println("4. Exit");
        System.out.print("Choose an option: ");
    }

    private static void addExpense(
            Scanner scanner,
            ExpenseTracker tracker,
            ExpenseFileStore store) {
        System.out.print("Description: ");
        String description = scanner.nextLine();
        System.out.print("Amount: ");
        String amountText = scanner.nextLine();
        System.out.print("Category: ");
        String category = scanner.nextLine();

        try {
            Expense expense = new Expense(description, new BigDecimal(amountText), category);
            tracker.addExpense(expense);
            try {
                store.save(tracker.getExpenses());
                System.out.println("Expense saved.");
            } catch (IOException exception) {
                System.out.println("Expense added in memory but could not be saved: " + exception.getMessage());
            }
        } catch (IllegalArgumentException exception) {
            System.out.println("Expense not added: " + exception.getMessage());
        }
    }

    private static void showExpenses(ExpenseTracker tracker) {
        if (tracker.getExpenses().isEmpty()) {
            System.out.println("No expenses recorded.");
            return;
        }

        int number = 1;
        for (Expense expense : tracker.getExpenses()) {
            System.out.println(
                    number + ". "
                            + expense.getDescription() + " | "
                            + expense.getAmount().toPlainString() + " | "
                            + expense.getCategory());
            number++;
        }
    }

    private static void showTotal(ExpenseTracker tracker) {
        System.out.println("Total: " + tracker.getTotal().toPlainString());
    }
}

What to compare in App.java

The menu loop and save handling remain intact. The updated addExpense() method catches invalid user input before the loop continues.

You did it. PocketLedger now rejects malformed expenses while keeping its menu available. Valid entries still survive a restart.

Secret mission

Add a Category Spending Summary

PocketLedger can total every expense, but it cannot show where the money went. Add a case-insensitive category summary that isolates one category without changing the save format.

Clean Up Your Resources

Clean Up Your Resources

PocketLedger runs entirely on your Linux computer with no ongoing costs. Decide whether to keep the project available, pause your work, or delete its local resources.

Resources you used:

  • Local pocketledger Maven project folder containing your source files.
  • Compiled output in target.
  • Saved expense records in expenses.txt.

Keep everything running

Keeping everything preserves your code and saved entries for more experiments. PocketLedger has no background service after the application exits.

  • Leave the pocketledger folder in its current location.
  • Keep expenses.txt to retain your recorded expenses.
  • Return to the open project in IntelliJ IDEA when you want to continue building.

Pause - I'll come back to this later

Pausing closes the application while preserving every project file for your return.

  • Enter 5 at the PocketLedger menu if the application is running.
  • Close IntelliJ IDEA.
  • Leave the pocketledger folder in place.

You will see Goodbye. before the application stops. Your source files and saved expenses remain available.

Delete - I don't want to use this again

Deleting the project can feel drastic because your saved entries go with it. Your development tools remain installed.

  • Enter 5 at the PocketLedger menu if the application is running.
  • Search your Linux file manager for the folder named pocketledger.
  • Select the exact pocketledger project folder from the search results.
  • Confirm that the selected folder is the Maven project open in IntelliJ IDEA.
  • Close IntelliJ IDEA.
  • Delete the selected pocketledger folder from your Linux file manager.
  • Clear your file manager's trash to complete the deletion.
  • Search for pocketledger again to confirm that this project folder no longer appears.

The source code and compiled output are now gone. The saved expense records are also gone.

Nice Work!

Nice Work!

Mission complete! You built PocketLedger as a persistent Java command-line expense tracker.

You've learned how to:

  • Built a menu-driven Java application from reusable expense classes inside a standard Maven project.
  • Managed expense objects with ArrayList through the List interface. Calculated exact decimal totals with BigDecimal.
  • Used file I/O to preserve valid expenses in expenses.txt across restarts. Restored saved entries during startup. Kept the menu available after invalid input. Reported file failures without discarding in-memory data.
  • Completed the optional Secret Mission by adding a case-insensitive category spending summary.

Ready to quiz yourself?