← Complete Projects

阅读中文版本

Build a Persistent Expense Tracker CLI with Reports

A command-line expense tracker that records dated expenses with categories, persists them to a CSV file, and prints monthly and per-category summaries with totals.

beginner~4 hnodejsclicsvreporting

Project goal

Build a CLI that records expenses with an amount, category, and date, stores them in a CSV file, and reports monthly and category breakdowns with totals and averages.

Final features

  • Add an expense with amount, category, and date, validated before it is stored.
  • List expenses filtered by month or category.
  • Summarise spending per category and per month with totals.
  • Persist to CSV so the data is editable and diffable in git.
  • Reject malformed amounts and dates with a clear message.

Architecture

A parser converts argv into a command object; a store reads and writes a CSV file with a header row; a report module groups records and computes totals; and a formatter prints aligned tables. Amounts are stored in minor units as integers to avoid floating-point drift.

Prerequisites

  • Node.js 20 LTS or later
  • Comfort with string parsing and date handling
  • A basic understanding of monetary rounding

Dependencies

  • node >=20 — File system access and the CLI entry point
  • vitest ^2.1.0 — Parsing, storage, and reporting tests

Directory structure

expense-cli/
├── src/
│   ├── parse.js
│   ├── money.js
│   ├── store.js
│   ├── report.js
│   └── cli.js
├── test/
│   ├── money.test.js
│   └── report.test.js
└── package.json

Phases

1. Argument parsing

Translate argv into a command object with validated subcommands and options.

Files: src/parse.js

export function parseArgs(argv) {
  const [command, ...rest] = argv;
  const options = {};
  const positionals = [];
  for (let i = 0; i < rest.length; i++) {
    const token = rest[i];
    if (token.startsWith('--')) {
      const [key, inline] = token.slice(2).split('=');
      options[key] = inline ?? rest[++i];
    } else positionals.push(token);
  }
  return { command, options, positionals };
}

export const COMMANDS = new Set(['add', 'list', 'report']);

Expected behavior: Both --month=2026-01 and --month 2026-01 parse to the same option, repeated positionals are preserved, and an unknown command is rejected by the caller.

2. Money module

Represent amounts as integer minor units and provide exact parse and format functions.

Files: src/money.js

export function parseAmount(input) {
  const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(String(input).trim());
  if (!match) throw new Error('invalid amount: ' + input);
  const [, sign, whole, frac = ''] = match;
  const cents = Number(whole) * 100 + Number((frac + '00').slice(0, 2));
  return sign === '-' ? -cents : cents;
}

export function sum(amounts) {
  return amounts.reduce((total, cents) => total + cents, 0);
}

Expected behavior: Parsing 19.99 gives 1999 cents, parsing 19.9 gives 1990, a negative sign is preserved, and summing many values never produces a fractional cent.

3. CSV store

Read and write a header-row CSV ledger with atomic writes and tolerant parsing of quoted fields.

Files: src/store.js

import { readFile, writeFile, rename } from 'node:fs/promises';

const HEADER = ['date', 'category', 'amountCents', 'note'];

export function toCsv(records) {
  const rows = [HEADER.join(',')];
  for (const r of records) rows.push([r.date, r.category, r.amountCents, escapeCsv(r.note ?? '')].join(','));
  return rows.join('\n') + '\n';
}

export function fromCsv(text) {
  return text.trim().split('\n').slice(1).filter(Boolean).map((line) => {
    const [date, category, amountCents, ...note] = parseCsvLine(line);
    return { date, category, amountCents: Number(amountCents), note: note.join(',') };
  });
}

function escapeCsv(value) {
  return /[",\n]/.test(value) ? '"' + value.replace(/"/g, '""') + '"' : value;
}

function parseCsvLine(line) {
  return line.match(/("([^"]|"")*"|[^,]*)(,|$)/g).slice(0, -1).map((f) =>
    f.replace(/,$/, '').replace(/^"|"$/g, '').replace(/""/g, '"'));
}

Expected behavior: A record with a comma in the note survives a CSV round-trip, and the header line is written exactly once.

4. Report grouping

Group records by category and by month and compute totals and averages as pure functions.

Files: src/report.js

export function byCategory(records) {
  const groups = new Map();
  for (const r of records) {
    const bucket = groups.get(r.category) ?? { category: r.category, total: 0, count: 0 };
    bucket.total += r.amountCents;
    bucket.count += 1;
    groups.set(r.category, bucket);
  }
  return [...groups.values()].sort((a, b) => b.total - a.total);
}

export function byMonth(records) {
  const groups = new Map();
  for (const r of records) {
    const month = r.date.slice(0, 7);
    groups.set(month, (groups.get(month) ?? 0) + r.amountCents);
  }
  return [...groups.entries()].map(([month, total]) => ({ month, total }))
    .sort((a, b) => a.month.localeCompare(b.month));
}

Expected behavior: Grouping a ledger with two categories and two months returns exact totals for each, and ties in category totals sort by a deterministic key.

5. CLI surface

Dispatch subcommands, validate inputs, and print aligned tables or summaries.

Files: src/cli.js

import { parseArgs, COMMANDS } from './parse.js';
import { parseAmount, formatAmount } from './money.js';
import { fromCsv, toCsv } from './store.js';
import { byCategory } from './report.js';

const { command, options } = parseArgs(process.argv.slice(2));
if (!COMMANDS.has(command)) {
  console.error('usage: expense <add|list|report> [options]');
  process.exit(1);
}

if (command === 'add') {
  const cents = parseAmount(options.amount ?? '');
  console.log('recorded ' + formatAmount(cents) + ' in ' + options.category);
} else if (command === 'report') {
  for (const g of byCategory([])) console.log(g.category.padEnd(12) + formatAmount(g.total));
}

Expected behavior: Adding with a malformed amount exits non-zero with a message, and reporting prints one aligned line per category with totals.

6. Tests and hardening

Cover parsing, money, storage, and reporting, and fix any failure the tests reveal.

Files: test/report.test.js

import { byCategory, byMonth } from '../src/report.js';

const ledger = [
  { date: '2026-01-02', category: 'food', amountCents: 1250 },
  { date: '2026-01-15', category: 'travel', amountCents: 8000 },
  { date: '2026-02-03', category: 'food', amountCents: 900 },
];

test('category totals add up', () => {
  expect(byCategory(ledger)).toEqual([
    { category: 'travel', total: 8000, count: 1 },
    { category: 'food', total: 2150, count: 2 },
  ]);
});

test('month totals split by month', () => {
  expect(byMonth(ledger)).toEqual([
    { month: '2026-01', total: 9250 },
    { month: '2026-02', total: 900 },
  ]);
});

Expected behavior: The report suite passes, a malformed amount throws before writing anything, and the ledger round-trips through CSV unchanged.

Testing

Run npx vitest. Money tests cover parsing, rounding, negatives, and formatting round-trips. Store tests round-trip records through a temporary CSV and assert the header is written once. Report tests group a fixture ledger by month and category and assert exact totals, including a month with no expenses.

Troubleshooting

Totals drift by a cent after many entries

Store amounts as integer minor units and sum them as integers; converting to floats at any intermediate step reintroduces the rounding the integer representation exists to avoid.

A note containing a comma breaks the CSV rows

Quote any field containing a comma, quote, or newline and double embedded quotes; the escape must happen on write and the unescape on read, symmetrically.

Monthly grouping is off by one day near midnight

Store the date as a plain ISO day string and group on its first seven characters, avoiding time-zone conversion entirely.

Deployment

Install as a global CLI or run from a checkout. The CSV ledger is portable and reviewable, so a team can keep it in a shared repository and read the diffs in a pull request.

Known limitations

  • A single CSV file is fine for personal use but is not designed for concurrent writers.
  • There is no currency conversion; amounts are assumed to share one currency.

Project overview

Expense tracking is a small problem that hides two classic pitfalls: floating-point money and date grouping across time zones. This project fixes both up front so the reports can be trusted.

Amounts are stored as integer minor units, dates as ISO days, and the data lives in a CSV file you can open in a spreadsheet or diff in git. Reports regroup the same records without re-reading anything.

The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.

The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.

It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.

Money handling is the decision that affects every number the tool prints, so it gets its own tiny module:

export function parseAmount(input) {
  const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(String(input).trim());
  if (!match) throw new Error('invalid amount: ' + input);
  const [, sign, whole, frac = ''] = match;
  const cents = Number(whole) * 100 + Number((frac + '00').slice(0, 2));
  return sign === '-' ? -cents : cents;
}

export function formatAmount(cents) {
  const sign = cents < 0 ? '-' : '';
  const abs = Math.abs(cents);
  return sign + Math.floor(abs / 100) + '.' + String(abs % 100).padStart(2, '0');
}

Parsing to integer cents once at the boundary means every later sum is exact, and formatting back is the only place that knows about a decimal point.

Setup

The standard library covers everything this tool needs, so the install is a single test dependency.

Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.

Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.

Dependency Version Why it is here
node >=20 File system access and the CLI entry point
vitest ^2.1.0 Parsing, storage, and reporting tests

Create the project and install:

mkdir expense-cli && cd expense-cli
npm init -y
npm install -D vitest@^2.1.0

Configure package.json with the CLI and test scripts:

{
  "name": "expense-cli",
  "type": "module",
  "scripts": {
    "expense": "node src/cli.js",
    "test": "vitest run"
  }
}

Point the store at a path from an environment variable so tests write to a temporary file instead of your real ledger.

Phase 1: Argument parsing

Translate argv into a command object with validated subcommands and options. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.

The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.

export function parseArgs(argv) {
  const [command, ...rest] = argv;
  const options = {};
  const positionals = [];
  for (let i = 0; i < rest.length; i++) {
    const token = rest[i];
    if (token.startsWith('--')) {
      const [key, inline] = token.slice(2).split('=');
      options[key] = inline ?? rest[++i];
    } else positionals.push(token);
  }
  return { command, options, positionals };
}

export const COMMANDS = new Set(['add', 'list', 'report']);

Acceptance for this phase: Both –month=2026-01 and –month 2026-01 parse to the same option, repeated positionals are preserved, and an unknown command is rejected by the caller.

Phase 2: Money module

Represent amounts as integer minor units and provide exact parse and format functions. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.

This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.

export function parseAmount(input) {
  const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(String(input).trim());
  if (!match) throw new Error('invalid amount: ' + input);
  const [, sign, whole, frac = ''] = match;
  const cents = Number(whole) * 100 + Number((frac + '00').slice(0, 2));
  return sign === '-' ? -cents : cents;
}

export function sum(amounts) {
  return amounts.reduce((total, cents) => total + cents, 0);
}

Acceptance for this phase: Parsing 19.99 gives 1999 cents, parsing 19.9 gives 1990, a negative sign is preserved, and summing many values never produces a fractional cent.

Phase 3: CSV store

Read and write a header-row CSV ledger with atomic writes and tolerant parsing of quoted fields. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.

The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.

import { readFile, writeFile, rename } from 'node:fs/promises';

const HEADER = ['date', 'category', 'amountCents', 'note'];

export function toCsv(records) {
  const rows = [HEADER.join(',')];
  for (const r of records) rows.push([r.date, r.category, r.amountCents, escapeCsv(r.note ?? '')].join(','));
  return rows.join('\n') + '\n';
}

export function fromCsv(text) {
  return text.trim().split('\n').slice(1).filter(Boolean).map((line) => {
    const [date, category, amountCents, ...note] = parseCsvLine(line);
    return { date, category, amountCents: Number(amountCents), note: note.join(',') };
  });
}

function escapeCsv(value) {
  return /[",\n]/.test(value) ? '"' + value.replace(/"/g, '""') + '"' : value;
}

function parseCsvLine(line) {
  return line.match(/("([^"]|"")*"|[^,]*)(,|$)/g).slice(0, -1).map((f) =>
    f.replace(/,$/, '').replace(/^"|"$/g, '').replace(/""/g, '"'));
}

Acceptance for this phase: A record with a comma in the note survives a CSV round-trip, and the header line is written exactly once.

Phase 4: Report grouping

Group records by category and by month and compute totals and averages as pure functions. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.

Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.

export function byCategory(records) {
  const groups = new Map();
  for (const r of records) {
    const bucket = groups.get(r.category) ?? { category: r.category, total: 0, count: 0 };
    bucket.total += r.amountCents;
    bucket.count += 1;
    groups.set(r.category, bucket);
  }
  return [...groups.values()].sort((a, b) => b.total - a.total);
}

export function byMonth(records) {
  const groups = new Map();
  for (const r of records) {
    const month = r.date.slice(0, 7);
    groups.set(month, (groups.get(month) ?? 0) + r.amountCents);
  }
  return [...groups.entries()].map(([month, total]) => ({ month, total }))
    .sort((a, b) => a.month.localeCompare(b.month));
}

Acceptance for this phase: Grouping a ledger with two categories and two months returns exact totals for each, and ties in category totals sort by a deterministic key.

Phase 5: CLI surface

Dispatch subcommands, validate inputs, and print aligned tables or summaries. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.

The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.

import { parseArgs, COMMANDS } from './parse.js';
import { parseAmount, formatAmount } from './money.js';
import { fromCsv, toCsv } from './store.js';
import { byCategory } from './report.js';

const { command, options } = parseArgs(process.argv.slice(2));
if (!COMMANDS.has(command)) {
  console.error('usage: expense <add|list|report> [options]');
  process.exit(1);
}

if (command === 'add') {
  const cents = parseAmount(options.amount ?? '');
  console.log('recorded ' + formatAmount(cents) + ' in ' + options.category);
} else if (command === 'report') {
  for (const g of byCategory([])) console.log(g.category.padEnd(12) + formatAmount(g.total));
}

Acceptance for this phase: Adding with a malformed amount exits non-zero with a message, and reporting prints one aligned line per category with totals.

Phase 6: Tests and hardening

Cover parsing, money, storage, and reporting, and fix any failure the tests reveal. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.

The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.

import { byCategory, byMonth } from '../src/report.js';

const ledger = [
  { date: '2026-01-02', category: 'food', amountCents: 1250 },
  { date: '2026-01-15', category: 'travel', amountCents: 8000 },
  { date: '2026-02-03', category: 'food', amountCents: 900 },
];

test('category totals add up', () => {
  expect(byCategory(ledger)).toEqual([
    { category: 'travel', total: 8000, count: 1 },
    { category: 'food', total: 2150, count: 2 },
  ]);
});

test('month totals split by month', () => {
  expect(byMonth(ledger)).toEqual([
    { month: '2026-01', total: 9250 },
    { month: '2026-02', total: 900 },
  ]);
});

Acceptance for this phase: The report suite passes, a malformed amount throws before writing anything, and the ledger round-trips through CSV unchanged.

Design trade-offs

Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.

Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.

Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.

Testing strategy

Run npx vitest. Money tests cover parsing, rounding, negatives, and formatting round-trips. Store tests round-trip records through a temporary CSV and assert the header is written once. Report tests group a fixture ledger by month and category and assert exact totals, including a month with no expenses.

Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.

Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.

Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.

The round-trip money test is the important one: parse then format must return the original string, which catches rounding bugs before they reach a total.

Extending the project

Budgets, recurring entries, and import from bank CSV each consume the same record shape. Because grouping is a pure function over records, a new report is a new function rather than a change to storage.

The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.