Finance API:
Backend Tracker
A Node.js and Express backend for expense tracking and spending analytics, in strict TypeScript. 31 endpoints across five routers, rotating refresh tokens with family-wide revocation on reuse, and a fully documented OpenAPI surface.
CORE CAPABILITIES
Advanced Auth
JWT-based auth with access/refresh token rotation and secure logout.
Expense Management
CRUD operations with soft deletes, duplicate detection, and recurring expenses.
Analytics Engine
Rolling averages, category distribution, and month-over-month comparisons.
Search & Filter
Advanced filtering by date, category, amount, and payment method with pagination.
Data Export
Export financial data to JSON or CSV formats for external analysis.
Production Ready
structured logging, error handling, and environment configuration.
The API surface
31 endpoints across five routers, every one of them documented in the OpenAPI spec the page on the right is rendering.
/api/auth/register/api/auth/login/api/expense/api/expense/search/api/analytics/summary/monthly/api/export/csv
FIVE ROUTERS, 31 ROUTES
/api/auth
4register, login, refresh, logout. The only unauthenticated surface in the API.
/api/user
6Profile read and write, preferences read and write, account deletion, and a lookup by id.
/api/expense
9Full CRUD, a filtered search, and three bulk operations — create, update and delete — each with their own Zod schema.
/api/analytics
10Daily, weekly, monthly, rolling-window and month-on-month summaries; category distribution, top categories and a per-category trend; behavioural and monthly insight rollups.
/api/export
2JSON and CSV, both accepting the same date-range and category options.
Four of the five routers call router.use(authMiddleware) once at the top rather than decorating each route. Adding a route to one of them cannot accidentally ship it unauthenticated, because authentication is a property of the router and not something the author has to remember.
12 QUERY PARAMETERS ON ONE ENDPOINT
The search schema is where most of the API's expressiveness lives, and it is a single Zod object rather than a hand-rolled query parser. Coercion is part of the schema — a date arrives as a string and leaves as a Date, a page number arrives as a string and leaves as a positive integer — so the service beneath never sees a string that should have been a number.
The defensive details are in the schema too rather than in the handler: limit is capped at 100 and defaults to 50, sortBy is an enum of four permitted columns rather than a free string, and three of the filters accept either an array or a single value so a one-element filter does not need special-casing by the caller.
Sessions
Refresh tokens come in families
The interesting question about a refresh token is not how it is issued, it is what the system does when one is presented twice. A long-lived credential that keeps working after it has been copied is worse than no refresh token at all, so the design here is built around detecting exactly that.
FOUR RULES
The access token is short and stateless
A 15-minute JWT carrying exactly two claims — the user id and a token family id. Nothing is looked up to validate it.
The refresh token is not a JWT at all
64 bytes from crypto.randomBytes, hex-encoded. It carries no claims, so it cannot be read or forged — it can only be matched. It is stored bcrypt-hashed, so the token table leaking does not hand anyone a session.
Every refresh rotates
Presenting a valid refresh token immediately marks it revoked and issues a fresh pair. A refresh token is single-use by construction rather than by convention.
Reuse burns the family
If a presented token fails the bcrypt comparison, every token sharing its accessTokenFamily is revoked in one write. An attacker replaying a stolen token does not get a session — they get the real user logged out, which is the loud failure you want.
WHY THE FAMILY ID EXISTS
Every login mints a random family id and stamps it into both the access token's claims and the stored refresh record. A chain of rotations inherits the same id, so the whole lineage of one login is addressable by a single indexed field.
That is what makes the reuse response cheap: revoking a compromised session is one updateMany on accessTokenFamily, not a walk back through a linked list of rotations. Logging out does the same thing deliberately, so signing out on one device ends that device's lineage without touching the others.
Account state is checked on the refresh path as well as at login. A user suspended or deleted mid-session stops being able to renew, which bounds how long a revoked account can keep using the API at fifteen minutes rather than seven days.
The model
Two dates, not one
An expense carries both an expenseDate — when the money was actually spent — and an entryDate for when it was recorded. People log expenses in batches days later, and collapsing those into one timestamp makes every monthly total subtly wrong. Analytics all filter on expenseDate; the entry date exists to explain a gap, not to be reported on.
Deletion is soft. A deletedAt timestamp is set and every read adds deletedAt: null. The export endpoints are the one place that can opt back in, via an includeDeleted flag — so a user taking their data out can choose to take the history with it.
Three compound indexes back that, all led by userId: userId with descending expense date, userId with category, and userId with deletedAt. The leading field is never optional, because no query in this API is ever legitimately cross-user.
Creating an expense runs a duplicate guard first: same user, same amount, within sixty seconds either side of the given date. Double-tap on a submit button is the failure this is actually for, and it comes back as a 400 with EXPENSE_DUPLICATE_DETECTED rather than a generic validation error, so a client can offer “yes, I meant it” instead of just failing.
ERRORS ARE TYPED
Twenty error codes in one enum, grouped by concern — auth, user, expense, validation, resource, generic. Every failure response carries a machine-readable code alongside the human message, so a client branches on AUTH_TOKEN_EXPIRED versus AUTH_TOKEN_REVOKED rather than matching on English.
The error middleware separates faults it expects from faults it does not. A Zod failure becomes a 400 with a per-field list of { field, message }. An operational AppError keeps its own status and code. Anything else is a 500 whose message is replaced with “Internal server error” in production — stack traces are attached only outside production, so a crash cannot leak internals to a caller.
WHAT I'D DO DIFFERENTLY
The analytics are not aggregation pipelines. Every summary loads the matching documents with a find() and reduces them in Node — totals, category frequency maps, zero-spend day counts, and a spending-spike filter that flags anything above twice the period's mean.
At one person's expense history that is genuinely fine, and it kept the logic readable and trivially testable. It does not survive growth: the whole result set crosses the wire for what should be a single scalar. The rewrite is well-defined — $match then $group for the summaries, $facet for the rollups that currently issue four queries in parallel — and the existing indexes already support it. It has not been done because nothing has made it necessary yet, which is the honest reason rather than a design one.
TECHNICAL ARCHITECTURE
core
Node.js
Runtime Environment
Express.js
Web Framework
TypeScript
Strict Mode & Type Safety
database
MongoDB
Document Database
Mongoose
ODM with Schema Validation
3 compound indexes
All led by userId
security
JWT
Access & Refresh Tokens
Bcrypt
Password Hashing
Zod
Runtime Validation
Helmet
Security Headers
tools
Swagger UI
API Documentation
Morgan
HTTP Request Logging
CORS
Cross-Origin Resource Sharing