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.
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 placesModel
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
doublefor money.0.1 + 0.2is0.30000000000000004, and totals drift by a paisa after a few hundred additions. Uselongcents orBigDecimal, 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 plusATOMIC_MOVEis 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, asmoneydoes above.
Interview angle
- Why not
doublefor currency? Binary floating point cannot represent 0.1 exactly, so rounding errors accumulate.BigDecimalmodels decimal arithmetic exactly;longminor units do too and are faster — the standard follow-up isBigDecimalwhen you need fractional quantities or multiple currencies,longcents when you do not. - What does
groupingByreturn and why does order matter? AHashMapbacked, unmodifiable map. Order-sensitive output needs aLinkedHashMapsupplier 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.
YearMonthvs comparing strings?YearMonth.from(date).equals(month)handles year boundaries (2025-12vs2026-01) that string prefix comparisons get wrong, and it reads like the requirement.
Milestones
Done when
- Add, list and summary match the target behaviour layout on a real data file.
- Every amount is stored and printed through cents arithmetic; no
doubleappears anywhere in the project. - Killing the process immediately after an add never corrupts the file (test by hand: add in a loop,
kill -9mid-loop, reload). --monthfiltering is correct across a year boundary, covered by a test with December and January expenses.- The test suite runs with no network, no database and no pre-existing files.
Stretch goals
- Add monthly budgets per category with a warning at 80 percent and an error at 100 percent, stored in the same file.
- Add
expense export --csvand round-trip it back in — watch the quoting when a description contains a comma. - Add
expense edit <id>andexpense delete <id>, keeping the id stable. - Pad the output into aligned columns and add a
--jsonflag with a dedicated renderer, the same split the Filestat CLI uses.