Production-Ready Backend

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.

31 endpointsRefresh-token families20 typed error codesFull OpenAPI docs
VIEW SOURCE

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.

POST/api/auth/register
POST/api/auth/login
POST/api/expense
GET/api/expense/search
GET/api/analytics/summary/monthly
GET/api/export/csv
API Documentation

FIVE ROUTERS, 31 ROUTES

/api/auth

4

register, login, refresh, logout. The only unauthenticated surface in the API.

/api/user

6

Profile read and write, preferences read and write, account deletion, and a lookup by id.

/api/expense

9

Full CRUD, a filtered search, and three bulk operations — create, update and delete — each with their own Zod schema.

/api/analytics

10

Daily, 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

2

JSON 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

startDateendDateminAmountmaxAmountcategoriestagspaymentMethodssearchTextpagelimitsortBysortOrder

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