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→$sortpipelines instead of fetching everything and reducing in JavaScript - Grouping by derived values with
$yearand$monthdate operators - Computing percentages inside the pipeline with
$sumand a second pass over the grouped totals - Validating query parameters (a
YYYY-MMmonth string) at the boundary - Restricting a field to an allowlist with a Mongoose
enum
Requirements
- An expense has an
amount, acategory, an optionalnote, and adate amountmust be a positive number — zero and negatives are rejected with400categorymust be one offood,transport,bills,shopping,other— anything else is400datedefaults to now when omitted, so quick entries need only an amount and a categoryGET /api/expensessupports optionalcategoryandfrom/todate 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
| Method | Endpoint | Request | Success Response |
|---|---|---|---|
| POST | /api/expenses | Body: amount, category, note?, date? | 201 — created expense |
| GET | /api/expenses | Optional query: category, from, to | 200 — array of expenses, newest first |
| GET | /api/expenses/:id | — | 200 — one expense, 404 if missing |
| DELETE | /api/expenses/:id | — | 204 — deleted, 404 if missing |
| GET | /api/summary/monthly | — | 200 — [{ year, month, total, count }], newest first |
| GET | /api/summary/by-category | Query: month as YYYY-MM | 200 — [{ category, total, percentage }] |
| GET | /api/summary/top | Optional query: month as YYYY-MM | 200 — the 5 largest expenses |
Edge Cases to Handle
| Request | Expected result |
|---|---|
Expense with amount: -500 or amount: 0 | 400 — amount must be a positive number |
Expense with category: "snacks" | 400 — category must be from the allowlist |
Expense with no date | 201 — date defaults to the current time |
GET /api/summary/by-category?month=2026-13 | 400 — invalid month format |
GET /api/summary/by-category?month=July | 400 — invalid month format |
| Summary for a month with no expenses | 200 — 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
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:
$groupwith a compound_idof{ year: { $year: "$date" }, month: { $month: "$date" } }, accumulatingtotal: { $sum: "$amount" }andcount: { $sum: 1 }.$sortby year and month descending — newest first.$projectto flatten_id.yearand_id.monthinto top-levelyearandmonthfields 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:
- Validate the month first. Match
monthagainst/^\d{4}-(0[1-9]|1[0-2])$/and reject with400before touching the database. Then compute the range: the first of that month, and the first of the next. $matchondate: { $gte: start, $lt: nextMonthStart }— half-open ranges have no midnight-of-the-31st bugs.$groupby"$category"withtotal: { $sum: "$amount" }.- Percentages need the grand total, which you only know after grouping. Either add a
$groupwith_id: nullto compute it and$projecteach 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. $sortbytotaldescending, 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
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"}'
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.
# 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
- Month-over-month comparison —
GET /api/summary/trendreturning each month's total alongside the delta from the previous month, using$setWindowFieldsor computing deltas from the (already tiny) monthly rows - Budgets per category — a
Budgetmodel with a monthly limit, and the category summary gaining anoverBudgetflag via$lookup - Add auth by bolting on the Authentication API — every expense gets an
owner, and every pipeline starts with a$matchon the logged-in user - CSV export —
GET /api/expenses/export?month=streaming the month's expenses as CSV with a properContent-Dispositionheader