Skip to main content

Expense Tracker API

Difficulty: Beginner–Intermediate · Builds on: routing, MongoDB & Mongoose, aggregation

You are building the backend for a personal expense tracker. Rizwan logs every expense as it happens — a bus fare, a grocery run, the electricity bill — and at the end of the month asks the questions that actually matter: how much did I spend this month? Where did it all go? What were my biggest hits?

The point of this exercise is letting the database do the math. Recording and listing expenses is plain CRUD; the summary endpoints are where you stop pulling documents into JavaScript and start pushing the computation down into MongoDB with the aggregation pipeline.

The Summary Pipeline

One collection is all this app needs, so there's no ERD to draw. The picture worth drawing is the pipeline every summary endpoint follows — documents flow through stages, and each stage reshapes what the next one sees:

Swap the $group key and you get a different report from the same shape: group by year/month for monthly totals, by category for the breakdown, or skip $group entirely and $sort + $limit for the top 5.

What You'll Practice

  • Writing $match$group$sort pipelines instead of fetching everything and reducing in JavaScript
  • Grouping by derived values with $year and $month date operators
  • Computing percentages inside the pipeline with $sum and a second pass over the grouped totals
  • Validating query parameters (a YYYY-MM month string) at the boundary
  • Restricting a field to an allowlist with a Mongoose enum

Requirements

  • An expense has an amount, a category, an optional note, and a date
  • amount must be a positive number — zero and negatives are rejected with 400
  • category must be one of food, transport, bills, shopping, other — anything else is 400
  • date defaults to now when omitted, so quick entries need only an amount and a category
  • GET /api/expenses supports optional category and from/to date filters
  • Every summary is computed by the database via .aggregate() — no summary route may call .find() and loop over the results in JavaScript. Fetching 10,000 documents over the wire to add up five numbers is the anti-pattern this exercise exists to break; the pipeline ships the five numbers instead.

API Contract

MethodEndpointRequestSuccess Response
POST/api/expensesBody: amount, category, note?, date?201 — created expense
GET/api/expensesOptional query: category, from, to200 — array of expenses, newest first
GET/api/expenses/:id200 — one expense, 404 if missing
DELETE/api/expenses/:id204 — deleted, 404 if missing
GET/api/summary/monthly200[{ year, month, total, count }], newest first
GET/api/summary/by-categoryQuery: month as YYYY-MM200[{ category, total, percentage }]
GET/api/summary/topOptional query: month as YYYY-MM200 — the 5 largest expenses

Edge Cases to Handle

RequestExpected result
Expense with amount: -500 or amount: 0400 — amount must be a positive number
Expense with category: "snacks"400 — category must be from the allowlist
Expense with no date201 — date defaults to the current time
GET /api/summary/by-category?month=2026-13400 — invalid month format
GET /api/summary/by-category?month=July400 — invalid month format
Summary for a month with no expenses200 — empty array, not an error

Build Guide

Step 1 — Project setup

The usual skeleton: npm install express mongoose, an express.json() app with /api/expenses and /api/summary routers, a central error handler, and a Mongoose connection — same shape as the Blog API.

Step 2 — The one model

models/expense.js
import mongoose from "mongoose";

const expenseSchema = new mongoose.Schema(
{
amount: {
type: Number,
required: true,
validate: [(v) => v > 0, "Amount must be a positive number"],
},
category: {
type: String,
required: true,
lowercase: true,
enum: ["food", "transport", "bills", "shopping", "other"],
},
note: { type: String, trim: true, maxlength: 200 },
date: { type: Date, default: Date.now },
},
{ timestamps: true },
);

expenseSchema.index({ date: -1 });
expenseSchema.index({ category: 1, date: -1 });

export default mongoose.model("Expense", expenseSchema);

The enum is the category allowlist and validate enforces the positive amount — the schema rejects bad data even if a route forgets to check. The date: -1 index serves both the newest-first listing and the $match stage of every summary.

Step 3 — CRUD routes

Plain CRUD, the same shape as the Task Manager, so no reference code. Carry over the usual habits: destructure exactly { amount, category, note, date } from the body as a field allowlist, guard /:id with mongoose.isValidObjectId, and translate Mongoose ValidationError into a 400 in the central error handler. For the list filters, build the query object conditionally — category if present, and date: { $gte: from, $lte: to } if either bound is given.

Step 4 — Monthly totals

GET /api/summary/monthly is your first pipeline, and it's yours to write:

  1. $group with a compound _id of { year: { $year: "$date" }, month: { $month: "$date" } }, accumulating total: { $sum: "$amount" } and count: { $sum: 1 }.
  2. $sort by year and month descending — newest first.
  3. $project to flatten _id.year and _id.month into top-level year and month fields and drop _id, so the response matches the contract.

No $match here — this report covers all time. Notice what you did not do: fetch every expense and bucket them in a JavaScript loop. The database returns one small row per month, no matter how many expenses exist.

Step 5 — Spend by category, with percentages

GET /api/summary/by-category?month=2026-06 follows the diagram at the top exactly:

  1. Validate the month first. Match month against /^\d{4}-(0[1-9]|1[0-2])$/ and reject with 400 before touching the database. Then compute the range: the first of that month, and the first of the next.
  2. $match on date: { $gte: start, $lt: nextMonthStart } — half-open ranges have no midnight-of-the-31st bugs.
  3. $group by "$category" with total: { $sum: "$amount" }.
  4. Percentages need the grand total, which you only know after grouping. Either add a $group with _id: null to compute it and $project each category's share, or — simpler — take the grouped rows (five at most, one per category) and compute percentages from their sum before responding. Deriving percentages from five already-aggregated rows is fine; summing raw documents in JavaScript is not. That's the line this exercise draws.
  5. $sort by total descending, so the biggest category leads.

An empty month simply produces an empty array from $match onward — return the 200 with [] and don't treat it as an error.

Step 6 — Top 5 expenses

The easiest one, deliberately last: optional $match (reuse the month-range logic from Step 5 — extract it into a helper), then $sort by amount descending, then $limit: 5. Three stages, no grouping.

Test It with curl

Log a few of Rizwan's June expenses
curl -s -X POST http://localhost:3000/api/expenses \
-H "Content-Type: application/json" \
-d '{"amount": 45000, "category": "bills", "note": "Electricity", "date": "2026-06-05"}'

curl -s -X POST http://localhost:3000/api/expenses \
-H "Content-Type: application/json" \
-d '{"amount": 12500, "category": "food", "note": "Groceries", "date": "2026-06-08"}'

curl -s -X POST http://localhost:3000/api/expenses \
-H "Content-Type: application/json" \
-d '{"amount": 800, "category": "transport", "note": "Careem to office", "date": "2026-06-09"}'
Summaries
curl -s http://localhost:3000/api/summary/monthly
curl -s "http://localhost:3000/api/summary/by-category?month=2026-06"
curl -s "http://localhost:3000/api/summary/top?month=2026-06"

The category breakdown should show bills at 45000 (about 77%), food at 12500, and transport at 800, in that order.

Failure paths
# negative amount -> 400
curl -s -X POST http://localhost:3000/api/expenses \
-H "Content-Type: application/json" \
-d '{"amount": -300, "category": "food"}'

# category not in the allowlist -> 400
curl -s -X POST http://localhost:3000/api/expenses \
-H "Content-Type: application/json" \
-d '{"amount": 1500, "category": "snacks"}'

# bad month format -> 400; empty month -> 200 with []
curl -s "http://localhost:3000/api/summary/by-category?month=2026-13"
curl -s "http://localhost:3000/api/summary/by-category?month=2020-01"

Stretch Goals

  1. Month-over-month comparisonGET /api/summary/trend returning each month's total alongside the delta from the previous month, using $setWindowFields or computing deltas from the (already tiny) monthly rows
  2. Budgets per category — a Budget model with a monthly limit, and the category summary gaining an overBudget flag via $lookup
  3. Add auth by bolting on the Authentication API — every expense gets an owner, and every pipeline starts with a $match on the logged-in user
  4. CSV exportGET /api/expenses/export?month= streaming the month's expenses as CSV with a proper Content-Disposition header