← All projects

Expense Tracker CLI

A pure-Java expense tracker with no framework — collections, streams, integer-cent money handling, JSON persistence with atomic writes, and tests for everything that matters.

CLI & ToolsStarter4-6 hours#cli#collections#streams#file-io#money#jsonJava 21Jacksonjava.timejava.nio.fileJUnit 5

Revise first: Java Collections FrameworkFile I/O & SerializationJava 8 & Functional ProgrammingJava Testing (JUnit, Mockito)

A command-line expense tracker: add an expense, list them with filters, print a monthly summary by category. There is no framework, no database and no server — which is exactly the point. With nothing else on stage you meet the parts Java actually gives you: which collection to reach for, why money must never be a double, how streams group and sum, and what happens to your data file when the process dies mid-write. Every habit here transfers directly to the service projects later in this kit.

Target behaviour

Build until your program prints this. The numbers will differ, the layout should not.

$ expense add "lunch" 249.50 --category food --date 2026-10-03
saved #42  2026-10-03  lunch  food  249.50

$ expense list --month 2026-10
2026-10-01  coffee   food    80.00
2026-10-03  lunch    food   249.50
2026-10-05  metro    travel  60.00

$ expense summary --month 2026-10
food     329.50
travel    60.00
total    389.50

$ expense add "tip" 10.005
error: amount has more than two decimal places

Model

An expense is an immutable record. Money is a long count of paise/cents — integer arithmetic cannot drift the way binary floating point does (0.1 + 0.2 != 0.3), and banks store minor units, not decimals.

public record Expense(long id, LocalDate date, String description,
                      String category, long cents) {

    public static long parseCents(String text) {
        return new BigDecimal(text).movePointRight(2).longValueExact();
    }

    public static String money(long cents) {
        return "%d.%02d".formatted(cents / 100, Math.abs(cents % 100));
    }
}

longValueExact() is the quiet hero: "10.005" and "abc" both throw instead of silently rounding or returning 0. Parsing and formatting are static functions on the record, so they are unit-testable with no files and no command line involved.

Storage with atomic writes

One JSON file, loaded fully into memory, saved as a whole. The save is two steps — write a temp file in the same directory, then rename over the real file — because a rename within one filesystem is atomic: a reader sees either the old file or the new one, never a half-written one. Writing directly to the data file can leave it truncated to zero bytes after a crash.

final class ExpenseStore {
    private final Path file;
    private final ObjectMapper om = new ObjectMapper().registerModule(new JavaTimeModule());

    ExpenseStore(Path file) { this.file = file; }

    List<Expense> load() throws IOException {
        if (!Files.exists(file)) return new ArrayList<>();
        return om.readValue(file.toFile(), new TypeReference<List<Expense>>() {});
    }

    void save(List<Expense> all) throws IOException {
        Path tmp = Files.createTempFile(file.getParent(), "expenses", ".tmp");
        om.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), all);
        Files.move(tmp, file, REPLACE_EXISTING, ATOMIC_MOVE);
    }
}

Two Jackson details you will hit within minutes: LocalDate needs JavaTimeModule registered or it serializes as an array of numbers, and records deserialize through their canonical constructor, so there is no no-arg constructor to add. New ids are max(existing) + 1 computed after loading — never a counter that resets per run.

long nextId(List<Expense> all) {
    return all.stream().mapToLong(Expense::id).max().orElse(0) + 1;
}

Queries with streams

The summary is a groupingBy with a downstream summingLong — a map from category to total, which you then sort by value descending.

static Map<String, Long> byCategory(List<Expense> all, YearMonth month) {
    return all.stream()
        .filter(e -> month == null || YearMonth.from(e.date()).equals(month))
        .collect(Collectors.groupingBy(Expense::category,
                 Collectors.summingLong(Expense::cents)));
}

Print categories sorted by total, then a grand total — mapToLong(...).sum() on the filtered stream. The month filter uses YearMonth.from(date), which compares year and month without the day, so --month 2026-10 needs no hand-rolled date math. groupingBy returns a HashMap with unspecified order, so the printing step, not the collector, decides the order.

Commands and exit codes

main dispatches on the first argument and keeps each command a static method taking String[] args and returning an exit code. Usage errors exit 2, runtime errors exit 1 — the convention shells already understand.

public static void main(String[] args) {
    int code = switch (args.length == 0 ? "" : args[0]) {
        case "add"     -> add(args);
        case "list"    -> list(args);
        case "summary" -> summary(args);
        default        -> { System.err.println(USAGE); yield 2; }
    };
    System.exit(code);
}

Dates come from LocalDate.parse — ISO format only, no SimpleDateFormat, no Date. If you want --date 3/10/2026, use a DateTimeFormatter with a locale and say which one; ambiguity in date parsing is how expense data silently migrates between months.

Testing

The money and summary logic is pure, so those tests are three lines each and run with no disk. The store gets a JUnit @TempDir, the same trick the Filestat CLI uses — the file system is the real dependency, no mocks needed.

class ExpenseTest {
    @Test
    void rejectsHalfCents() {
        assertThatThrownBy(() -> Expense.parseCents("10.005"))
            .isInstanceOf(ArithmeticException.class);
    }

    @Test
    void sumsByCategory(@TempDir Path dir) throws IOException {
        var store = new ExpenseStore(dir.resolve("expenses.json"));
        var all = List.of(new Expense(1, LocalDate.parse("2026-10-01"), "coffee", "food", 8000),
                          new Expense(2, LocalDate.parse("2026-10-03"), "metro", "travel", 6000));
        store.save(all);

        assertThat(Expense.parseCents("249.50")).isEqualTo(24950);
        assertThat(Main.byCategory(store.load(), YearMonth.of(2026, 10))).containsEntry("food", 8000L);
    }
}

A round-trip test proves the JavaTimeModule setup: save, load, assert equal. That is the test that fails the first time you forget the module.

Bugs you will hit

  • double for money. 0.1 + 0.2 is 0.30000000000000004, and totals drift by a paisa after a few hundred additions. Use long cents or BigDecimal, never floating point.
  • Writing the JSON file in place. om.writeValue(file.toFile(), all) truncates first and writes second; a crash in between loses every expense. Temp file plus ATOMIC_MOVE is the fix.
  • Forgetting JavaTimeModule. Dates appear as [2026,10,3] and a load of older data fails. The round-trip test catches this immediately.
  • Id generation with a counter field. Restart the process and the counter restarts, overwriting id 1. Always recompute from the loaded list.
  • String.format("%.2f", cents / 100.0). The division alone reintroduces floating point. Format from the integer parts, as money does above.

Interview angle

  • Why not double for currency? Binary floating point cannot represent 0.1 exactly, so rounding errors accumulate. BigDecimal models decimal arithmetic exactly; long minor units do too and are faster — the standard follow-up is BigDecimal when you need fractional quantities or multiple currencies, long cents when you do not.
  • What does groupingBy return and why does order matter? A HashMap backed, unmodifiable map. Order-sensitive output needs a LinkedHashMap supplier or — simpler — sorting the entry set when printing.
  • How do you make the file write crash-safe? Write to a temp file in the same directory, fsync, then atomic rename. The rename is the commit point; there is no torn state.
  • YearMonth vs comparing strings? YearMonth.from(date).equals(month) handles year boundaries (2025-12 vs 2026-01) that string prefix comparisons get wrong, and it reads like the requirement.

Milestones

0 of 6 milestones

Done when

Stretch goals