Skip to content

About

Track recurring subscriptions and what they cost. A heavily commented full-stack learning project: FastAPI + PostgreSQL + React, containerised with Docker Compose and built by GitHub Actions.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Subscription Tracker renewal icon

Subscription Tracker

Tests Build and push images Publish API docs

A small app for tracking recurring subscriptions (Netflix, HBO, etc.), built as a learning project for Docker, PostgreSQL, and CI/CD to a cloud host. See PLAN.md for the full project plan and milestones.

Live: https://subscriptionstrack.com (API at https://api.subscriptionstrack.com/docs)

Because it is a learning project, the code is written to be read: every source file opens with a comment explaining what it is for, and the non-obvious decisions are explained where they were made rather than in a document that drifts out of date. This README is the guided tour that ties those files together — what each layer is responsible for, and why it is a separate layer at all.

Stack

  • Backend: FastAPI + SQLAlchemy (Python 3.13)
  • Frontend: React 19 (Vite)
  • Database: PostgreSQL 18
  • Auth: JWT bearer tokens, bcrypt-hashed passwords
  • Cache: Redis 7, optional — the backend fails open without it
  • Four containers locally (db, redis, backend, frontend), orchestrated with Docker Compose; images published to GHCR by GitHub Actions and deployed over SSH to one UpCloud server in Helsinki (fi-hel1): Caddy, backend and frontend under Docker Compose, an UpCloud-managed PostgreSQL 18 on a private network, and no Redis (see deploy/README.md and PLAN.md, milestone 12). Until 2026-10-11 it ran on Azure Container Apps and Neon; both have been shut down.
  • Languages: English and Finnish, picked on the sign-in screen or in the Account dialog

Running locally

  1. Copy the example env file:

    cp .env.example .env
    
  2. Generate a signing key for the auth tokens and put it in .env as SECRET_KEY (the backend refuses to start without one):

    python -c "import secrets; print(secrets.token_hex(32))"
    

    .env is gitignored — the real key never gets committed.

  3. Start everything:

    docker compose up --build
    
  4. Open:

Data persists across restarts in a named Docker volume (pg18_data). Coming from a checkout that ran Postgres 16: the new image starts on an empty volume, and the old data stays in db_data until you remove it with docker volume rm <project>_db_data (docker volume ls shows the name). To reset the database entirely:

docker compose down -v

The environment variables

Compose reads .env automatically and passes these through to the containers that need them (.env.example documents the same list):

Variable Used by Notes
POSTGRES_DB db Database created on first start.
POSTGRES_PASSWORD db Superuser password for the postgres role.
DATABASE_URL backend Full SQLAlchemy URL. Host is db, not localhost — see Docker & Compose.
REDIS_URL backend The cache. If Redis can't be reached, every read falls through to Postgres — see cache.py.
CORS_ORIGINS backend Comma-separated origins allowed to call the API. Locally the Vite dev server.
SECRET_KEY backend Signs the JWTs. Changing it logs everyone out.
TURNSTILE_SECRET_KEY backend Optional. Turns on the Cloudflare Turnstile bot check on /register and /password-reset — see Accounts. Set it together with the frontend's site key.
TURNSTILE_HOSTNAMES backend Required with TURNSTILE_SECRET_KEY. Comma-separated hostnames a token may be solved on (production: subscriptionstrack.com; Cloudflare's test secret: example.com). Left empty, the check refuses every token.
EMAIL_DAILY_CAP backend Most emails sent in any 24 hours, all addresses together (default 90; Resend's free tier stops at 100).
TRUST_FORWARDED_FOR backend Production only. Key rate limits on the last X-Forwarded-For entry, which is the real client behind Caddy. Leave false without a proxy.
EMAIL_BACKEND backend console (default) logs verification and reset emails instead of sending them; resend sends through Resend. See Email.
RESEND_API_KEY backend Required when EMAIL_BACKEND=resend.
EMAIL_FROM backend Sender, on a domain verified in Resend.
APP_URL backend The frontend's address, used to build the links in emails.
FX_API_URL backend Optional. Where exchange rates come from; defaults to Frankfurter's ECB rates (https://api.frankfurter.dev/v1). Free and keyless.
VITE_API_URL frontend Baked into the browser bundle, so it must be an address your browser can reach. The production image reads API_URL at container start instead, so one image works against any backend.
VITE_TURNSTILE_SITE_KEY frontend Optional. Turnstile's public site key, which shows the widget on the signup and reset forms. The production image reads TURNSTILE_SITE_KEY at container start instead.

The shape of the whole thing

Browser (localhost:5173)
   │  fetch() with Authorization: Bearer <jwt>
   ▼
┌──────────────────────────────────────────────┐
│ frontend container — Vite dev server (React) │
└──────────────────────────────────────────────┘
   │  HTTP/JSON, cross-origin (CORS)
   ▼
┌──────────────────────────────────────────────┐
│ backend container — uvicorn → FastAPI        │
│                                              │
│   main.py     HTTP: routes, status codes     │
│   auth.py     tokens, password hashing       │
│   mailer.py   verification and reset emails  │
│   cache.py    Redis cache-aside (fails open) │
│   schemas.py  what JSON may go in and out    │
│   crud.py     the queries                    │
│   models.py   the tables                     │
│   database.py engine + per-request session   │
└──────────────────────────────────────────────┘
   │  SQL over Docker's internal network (db:5432);
   │  cached reads from the redis container (redis:6379)
   ▼
┌──────────────────────────────────────────────┐
│ db container — PostgreSQL 18                 │
│   volume pg18_data → survives `compose down` │
└──────────────────────────────────────────────┘

The backend split is the part worth internalising, because it is the same split most server applications end up with under different names:

  • models.py describes the database.
  • schemas.py describes the API.
  • Keeping them apart is what lets the two differ on purpose. models.User has a hashed_password column; schemas.User has no such field, so the hash cannot leak through a response even if a handler returns the whole row. Likewise the client may not send id or user_id on create, because the create schema simply has nowhere to put them.
  • crud.py knows SQL but nothing about HTTP — it never raises a 404.
  • main.py knows HTTP but writes no SQL — it turns None from crud into a 404, and a Pydantic model into a validated 201.

Layer by layer

1. PostgreSQL — docker-compose.yml

Runs from the official postgres:18 image (what production runs), pinned to a major version so a rebuild can't silently land a breaking upgrade.

Two Docker concepts do the real work here:

  • The named volume pg18_data. Container filesystems are ephemeral: without this, every docker compose down would destroy the database. The volume maps Postgres's data directory to storage Docker manages outside the container, so it outlives the container and is only removed by an explicit down -v.
  • The healthcheck. Compose polls pg_isready so backend can wait for condition: service_healthy — Postgres accepting connections, not merely "the container started". Without it the backend races the database on a cold start and crashes on the first connection attempt.

The 5432:5432 port mapping is a convenience for GUI clients on your Mac. The backend does not use it; containers reach each other over Compose's internal network instead.

2. SQLAlchemy models — backend/app/models.py

Python classes that map to tables. This file is the source of truth for the schema; Alembic is what applies it to a database.

Four tables: users, categories, subscriptions, and subscription_groups, which links the runs of a service cancelled and later reactivated. Details worth noticing:

  • Numeric(10, 2) for money, never Float. Binary floating point cannot represent 0.10 exactly; summing a column of them drifts. Numeric maps to Postgres NUMERIC and arrives in Python as Decimal, which is exact.
  • user_id is nullable=False and indexed. Not-null means a row can never end up orphaned and visible to everyone; the index exists because every query in crud.py filters on it.
  • Constraints live in the database, not only in code. users.email is unique=True, so a duplicate signup is rejected by Postgres even if two registrations race past the application's own check. categories carries a composite UniqueConstraint(user_id, name): two users may both have "Music", one user may not have it twice.
  • Subscription.category is a plain string, not a foreign key to categories. That is a deliberate trade: a subscription is never blocked by a missing category row, and clients can keep sending just a name. crud.ensure_category is what keeps the two in step.

Editing this file is only half of a schema change; the other half is a migration, below.

3. Migrations — backend/alembic/

Alembic owns the schema. This used to be Base.metadata.create_all() at startup, which creates missing tables and never alters an existing one — so every column added (user_id, then cancelled_date and started_date) cost a docker compose down -v or a hand-written ALTER TABLE. Survivable while the only data is local test rows, and not survivable at all once a deployed database holds anything worth keeping.

The backend container runs alembic upgrade head before uvicorn starts, from entrypoint.sh — so docker compose up needs no migration step of its own. Three details in how that is wired:

  • It is an ENTRYPOINT, not part of the CMD. A command: in docker-compose.yml replaces CMD but not ENTRYPOINT, so overriding how the app starts (as local dev does, to add --reload) cannot skip the migration.
  • A failed migration stops the container. Better a loud failure on start than an app serving requests against a schema its code does not match.
  • The first revision adopts a database that predates Alembic. It creates each table only if it is absent, so running it against an existing Compose volume records the revision instead of failing on "table users already exists". Migrations could otherwise only be adopted by throwing the data away.

Adding a schema change, from backend/:

alembic revision --autogenerate -m "what changed"   # diff models.py, draft it
alembic upgrade head                                # apply it

Always read what --autogenerate produced: it diffs models.py against the live database and is good at columns and indexes, but it cannot know that a new not-null column needs a default for existing rows, and it does not see a rename — only a drop and an add.

alembic current shows where a database stands, alembic downgrade -1 steps back one revision.

The pairing that keeps this honest is in the test suite: the fixtures build the schema from models.py, and test_migrations.py asserts that alembic upgrade head on an empty database produces exactly that same schema. A model edited without a migration passes every other test and fails that one.

4. Pydantic schemas — backend/app/schemas.py

The API contract. FastAPI uses these classes to parse and validate every request body, and to shape every response.

Validation is declarative — you write the type, not the check:

  • EmailStr rejects a malformed address before it reaches the database.
  • password: str = Field(min_length=8, max_length=72) — the upper bound exists because bcrypt hashes at most 72 bytes and raises beyond that. Declaring it turns a would-be 500 into a clean 422.
  • StringConstraints(strip_whitespace=True, min_length=1) on category names means " " can never become an unpickable blank category.
  • @model_validator handles rules spanning two fields, like "cancelled before started" — a typo that would otherwise total silently to zero every month.

A schema failure produces a 422 whose detail is a list of per-field objects, whereas HTTPException produces a detail string. That is the exact divergence formatError in frontend/src/api.js flattens.

Three patterns recur here and are worth recognising:

  • from_attributes=True lets a schema be built straight from a SQLAlchemy row, reading attributes instead of dict keys. It is why handlers can return ORM objects directly.
  • Separate Create / Update / Read classes. SubscriptionUpdate makes every field optional so PUT can carry one field; crud then uses model_dump(exclude_unset=True) so absent fields aren't overwritten with None. The distinction between "sent as null" and "not sent" is real, and this is how it survives.
  • Money = Annotated[Decimal, PlainSerializer(float)]. Pydantic v2 renders a bare Decimal as a JSON string ("15.99"). The summary routes predate having a response_model and emitted a number. Keeping Decimal for the arithmetic and converting only at serialization preserves both the exactness and the wire format.

5. crud.py — backend/app/crud.py

Every database operation, and nothing else. No HTTPException, no status codes: a function that finds nothing returns None, and the route decides that means 404. That separation is what would let the same functions serve a CLI or a background job.

The security boundary lives here. Every subscription and category function takes a user_id and filters on it. The route handlers pass the id from the verified token — never one supplied by the client — so there is no request shape that reads another account's rows.

Two techniques worth studying:

  • get_categories does one grouped query with an outer join, returning each category with a count of the subscriptions using it, instead of a count query per category. Listing N categories stays one round trip rather than N+1. The join matches on lowercased names, since a subscription may have been saved with different capitalisation.
  • Transaction boundaries are chosen, not accidental. ensure_category calls db.add but never db.commit, so the new category and the subscription that introduced it are saved together or not at all. import_backup is one transaction over the whole file: a file that fails half way leaves the account exactly as it was. And because the session is autoflush=False, a category added a moment ago is invisible to a query until commit — which is why the import resolves names against a dict read once up front instead of asking the database per row.

6. main.py — backend/app/main.py

The HTTP layer, and the only file that knows what a status code is.

Routes as annotated functions. @app.post("/subscriptions", response_model=schemas.Subscription, status_code=201) says what the route accepts and returns. response_model is not documentation — it filters: fields absent from the schema are dropped on the way out.

Dependency injection with Depends is the concept that carries the most weight. Two dependencies appear on nearly every route:

  • db: Session = Depends(get_db) — database.py defines get_db as a generator. FastAPI runs it up to the yield before the handler, hands over the session, and runs the finally after the response is sent — so the session is closed even if the handler raised. Dependencies are cached per request, so the route and the auth dependency share one session, and therefore one transaction.
  • current_user: models.User = Depends(auth.get_current_user) — validates the token and returns the User, or rejects with 401 before the handler body runs. That is why delete_subscription never asks "is this user logged in?": by the time it executes, they are.

Query parameters are typed too. billing_cycle: BillingCycle | None means ?billing_cycle=weekly comes back as a 422 instead of quietly matching nothing; year: int = Query(ge=1970, le=9999) bounds the range without an if.

Status codes are chosen to mean something. 409 rather than 400 for a duplicate category — the request is well-formed, it just collides. 409 again when deleting a category still in use, with the error naming how many subscriptions would be affected and what to pass (reassign_to or detach) rather than silently stripping the category off rows the caller forgot about.

CORS middleware. The page is served from localhost:5173 and the API from localhost:8000 — different origins, which browsers block by default. CORSMiddleware allows the origins listed in CORS_ORIGINS: the dev server locally, the frontend's own domain in production. Because the token travels in a header rather than a cookie, no CSRF or SameSite configuration is needed.

The OpenAPI metadata is functional. The title, description and openapi_tags on the FastAPI(...) call, plus the tags= on every route, are what /docs and /redoc render — all generated from the same /openapi.json that the type annotations feed, and the same document that gets published to GitHub Pages. Every docstring and description= on a Query is therefore public API documentation, not just a note to the next reader.

Rate limits come from slowapi: the routes that take a password or send an email are limited per client IP (/register and /token at 5/minute, reset and verification mail per hour). The counts are kept in memory, per process.

Note the routes are def, not async def. That is correct here: SQLAlchemy's synchronous Session blocks, and FastAPI runs plain def handlers in a threadpool. Marking them async without an async driver would block the event loop and hurt concurrency.

7. auth.py — backend/app/auth.py

Password hashing, token minting, and the dependency that turns an Authorization header into a User.

  • Passwords are bcrypt-hashed, never stored or logged. bcrypt generates a random salt per call and embeds it in the output, so two users with the same password still get different hashes. checkpw compares in constant time.
  • SECRET_KEY is read with no fallback. A hardcoded default would get committed, and anyone reading the repo could then mint a token for any account. Refusing to start is the safer failure.
  • A JWT is signed, not encrypted. Anyone holding one can read its claims — paste one into jwt.io and see. That is fine for a user id and an expiry, and the reason nothing secret goes in there. What the signature buys is that sub cannot be changed to another user's id without the key.
  • The user is loaded fresh from the database on every request, not trusted from the token's contents, so a deleted account stops working immediately instead of remaining valid until its token expires.
  • Failures are deliberately indistinguishable. A bad signature and an expired token give the same 401; login answers "incorrect email or password" for both a missing user and a wrong password, so the endpoint can't be used to enumerate which addresses have accounts.
  • /token follows the OAuth2 password flow, which fixes the field names as username and password — so the email goes in username. Following the spec is what makes the Authorize button on /docs a real login.

Each token also carries the user's token_version (tv). Changing or resetting the password bumps it, so every token issued before then stops working: that is the "signs you out on other devices" in the Account dialog. Logout itself is still purely client-side. A single token can't be revoked on its own; that would need a blocklist, which is out of scope here.

8. React frontend — frontend/src/

Vite serves the app in dev and builds it to static files for production. There is no framework beyond React itself, no router and no state library — one screen, so useState is the right amount of machinery.

The UI is the spending dashboard from the design handoff: a headline total for a selected period, a trend strip, four KPIs, spend by category, the charges coming up, the full subscription list (edited inline on desktop and in a sheet on mobile), and the import and export panel at the page foot. The Account dialog holds the language picker, change password, the email confirmation status and account deletion. The split is App.jsx for the data and the error states, dashboard/ for the view. That line is where the design's most demanding rule lives: a failed fetch must never blank the page — the last known good data stays on screen under a banner saying how old it is, which only works if the thing holding the data is not the thing rendering the failure.

  • Every colour, size and space is a token. The design system's stylesheet is vendored as modernist.css and is the source of truth; dashboard.css only arranges things. Zero border radius anywhere, everything flush left, 2px rules between major sections and 1px between peer rows.

  • The period drives everything. One { view, year, month } selection feeds the headline, the KPIs, the category bars, the Coming up list and the trend highlight, so they cannot disagree. The figures behind it are the server's real month-by-month totals from /subscriptions/summary/spend, one request per year in the 2025–2027 range.

  • Errors appear where the user can act on them. Field problems render at the field, transport and auth problems as a strip at the top of the page, a record deleted elsewhere in the row it affects. Nothing is a toast.

  • api.js is the only file that knows the backend exists. One request() wrapper attaches the bearer token, normalises errors, and handles 204 No Content (a DELETE has no body to parse). Every other module calls named functions like getSubscriptions().

  • The 401 path is centralised. When the backend rejects a token, request() clears it and dispatches an auth-expired event. What App.jsx does with it depends on whether there is a page worth keeping: with no data it drops to the login screen, and with a rendered dashboard it shows a quiet strip under the header instead. The figures are still worth reading — only writes will fail — and signing in from there keeps the period, sort and scroll position.

  • The token lives in localStorage, so a reload keeps you logged in while a different browser or private window starts logged out. App.jsx initialises its state lazily from it, then calls /me to check the token is still valid before showing the app.

  • VITE_API_URL is localhost:8000, not backend:8000. Only variables prefixed VITE_ are exposed to client code, and they are inlined into the bundle at build time — this value is used by code running in your browser, which knows nothing of Docker's internal network. It is also why the signing key is never a VITE_ variable.

  • services.js is a client-side catalogue of well-known services: the brand tile drawn beside a name, and a typical price for the empty state's one-tap tiles. Nothing about it is stored — the API has no home for a brand colour or a monogram — so adding a service is an edit to one file rather than a migration.

  • backup.js reads and diffs an import file — .csv and .json both become the body POST /import accepts, and the summary dialog's Add / Update / Unchanged ledger is computed from the real records on screen rather than estimated. Both halves are here rather than on the server because the design requires the diff before anything is written; a bad row is reported by name and row number, since editing that file is the user's only repair path.

  • Every user-facing string goes through i18n.js. The English and Finnish strings live in locales/, and npm run check:locales fails if one language is missing a key the other has. The choice is remembered per browser; the backend doesn't know it, which is why emails are English only.

  • renewals.js mirrors the backend's date arithmetic, because /subscriptions/upcoming is anchored to today while the period picker reaches back to 2025 and forward to 2027. It is a mirror, not the truth; the comment at the top says exactly where it can differ.

9. Docker & Compose

backend/Dockerfile — python:3.13-slim for a smaller image and attack surface. requirements.txt is copied and installed before the app code, so Docker's layer cache reuses the install step on every rebuild where dependencies haven't changed; only the fast COPY app layer below it re-runs. Its ENTRYPOINT is entrypoint.sh, which applies migrations and then execs the CMD — exec so uvicorn becomes PID 1 and receives Docker's SIGTERM directly, instead of a shell swallowing it and the container being killed after the grace period.

frontend/Dockerfile is a multi-stage build, the technique worth taking away from this project. The build stage uses Node to compile React into static files; the production stage copies just those files into nginx:alpine. Node is needed to build the app but not to serve it, so it never ships — the published image is a web server plus static assets.

docker-compose.yml wires the three together for local development:

  • Service names are hostnames. Compose puts every service on one network and gives it a DNS name matching its key, which is why DATABASE_URL points at db:5432. This name resolves only between containers.
  • depends_on: condition: service_healthy waits for a real readiness signal, not just process start.
  • Bind mounts give hot reload. ./backend/app:/app/app maps your source into the container so uvicorn's --reload picks up edits without a rebuild. Only source is mounted — dependencies installed by the Dockerfile stay in the image.
  • Dev overrides production defaults here, not in the Dockerfile. Compose overrides the backend CMD to add --reload, and targets the frontend's build stage to run Vite's dev server instead of Nginx. The Dockerfiles keep the production forms, which is exactly what CI publishes. Note what the override does not reach: CMD is replaced, ENTRYPOINT is not, so local dev still gets its migrations applied.

Production runs a different Compose file, deploy/compose.prod.yml: the same two published images, pinned to a sha- tag, behind a Caddy reverse proxy that terminates TLS and serves a maintenance page while a flag file exists. It has no db service, because the database is UpCloud's managed PostgreSQL, and no Redis. How the server is set up, deployed to and recovered is in deploy/README.md.

Four workflows:

Workflow Runs on Does
test.yml pushes to main and pull requests The backend test suite, twice — against SQLite and against Postgres 18. Skips the work when backend/ didn't change. Its deploy-config job checks the production files under deploy/: docker compose config, the Caddyfile, shellcheck, and the deploy/tests suite, including the edge tests.
frontend-visual.yml pushes to main and pull requests Playwright screenshots of the frontend against fixture data, compared with the committed baselines. Skips the work when frontend/ didn't change.
build-and-push.yml pushes to main Builds both images, publishes them to GHCR, and deploys them to the UpCloud server over SSH (deploy-upcloud)
docs.yml pushes to main touching backend/** or docs/** Generates openapi.json from the app and publishes the API reference

The two test workflows also run on pull requests, because a suite that only runs after a merge reports the problem too late to be worth much. They have no paths: filter: their jobs are required status checks on main, and a required check that never runs blocks the merge forever. A filter step inside the job skips the work instead, and the check still reports. test.yml runs both database legs because they are not the same database in the way that matters — Postgres returns a Numeric column as a Decimal, SQLite as a float — so Postgres is the leg that must be green, and the SQLite leg is what stops the zero-setup pytest path from quietly rotting.

Images. Every push to main builds both and pushes them to GitHub Container Registry:

  • ghcr.io/<owner>/subscription-tracker-backend
  • ghcr.io/<owner>/subscription-tracker-frontend

Each is tagged latest and sha-<short commit> — the immutable tag is what a deployment should pin to, since latest moves. The frontend image is the Nginx production stage, never the Vite dev server Compose runs locally.

Patterns worth copying, visible across the three: a matrix runs one job definition twice instead of two near-identical copies (once per image in the build, once per database in the tests); paths/paths-ignore keep a workflow from running for commits it cannot be affected by; concurrency with cancel-in-progress cancels a superseded run — except in docs.yml, where cancelling half way through a deployment is how a site ends up broken; and permissions grants the automatic GITHUB_TOKEN only what each job needs, so the build can publish images but cannot push commits, and the tests can do neither.

The packages inherit this repository's public visibility, so anyone can pull them without logging in. Every push to main also rolls the new images out to the live deployment above (milestone 12), via build-and-push.yml's deploy-upcloud job, pinned to the sha- tag. The job connects with a key that can only run deploy/bin/deploy.sh with a tag; the script waits for the health checks and rolls back by itself if the new version fails them.


A request end to end

POST /subscriptions, from the click to the row appearing — every layer above, in order.

  1. Browser. createSubscription(data) in api.js builds a fetch with Content-Type: application/json and Authorization: Bearer <token>. Note what the body does not contain: any notion of who owns the row.
  2. CORS preflight. Because the request carries a custom header, the browser first sends OPTIONS /subscriptions and waits. CORSMiddleware answers it; the handler never runs. Only then is the real POST sent. (This is why a missing origin shows up as an opaque network error rather than a 4xx.)
  3. uvicorn parses the HTTP and calls the ASGI app.
  4. Routing. FastAPI matches method + path to create_subscription.
  5. Dependencies resolve first. get_db opens a session and suspends at its yield. oauth2_scheme pulls the bearer token. get_current_user verifies the signature and expiry, then loads the User — using the same cached session. Any failure here is a 401 and the handler never runs.
  6. Body validation. The JSON is parsed into SubscriptionCreate: cost becomes Decimal, dates become date, billing_cycle becomes the enum. A bad payload is a 422 nobody had to write.
  7. The handler runs in a threadpool (it is def) and delegates to crud.create_subscription, which unpacks the schema into a model, adds user_id separately from the token, registers the category, defaults started_date to today, then add → commit → refresh (the refresh loads the database-assigned id).
  8. The response model shapes the output. The returned ORM object is read through schemas.Subscription — possible thanks to from_attributes=True — which drops anything not on the schema and encodes Decimal and date to JSON. Status 201.
  9. Back out through CORS middleware, which stamps the Access-Control-Allow-Origin header the browser requires.
  10. Teardown. FastAPI resumes get_db; its finally closes the session and returns the connection to the pool.
  11. Frontend parses the JSON and gets the new id, which React needs as a list key and for later PUT/DELETE.

When the token has expired, the path diverges at step 5: no body is ever read, no database write is attempted, and the 401 sends the UI back to the login screen.


Current UI mock

The clickable current UI mock uses the real React components with sample API responses. Changes remain in the browser tab; Reset sample restores the fixture. Its clock is fixed at 15 Sep 2026 so the period, renewals, and figures are stable for design review. To run it locally, start npm run dev in frontend/ and open http://localhost:5173/ui.html.

Design proposals for open issues live in docs/mocks/ as standalone pages that need no build, and are published next to the UI mock:

API reference

Everything except /health, /register, /token, /verify-email and the two /password-reset routes requires Authorization: Bearer <token>, and only ever sees the calling user's own data.

Published reference: https://juusimaa.github.io/subscription-tracker — the full API, browsable without running anything. It is generated from the app itself: .github/workflows/docs.yml imports the FastAPI application on every push to main, dumps its openapi.json, and publishes it with the Redoc page in docs/index.html. Nothing there is written by hand, so it cannot drift from the routes it describes. It is read-only: to send requests, use the live API's own Swagger UI at https://api.subscriptionstrack.com/docs. (A Swagger UI on GitHub Pages could not do that -- the API's CORS settings only allow the app's frontend.)

Running locally, the same spec is served interactively at http://localhost:8000/docs (Swagger UI) and http://localhost:8000/redoc.

Method Path What it does
GET /health Readiness check, used by Docker's healthcheck: 200 only if a SELECT 1 reaches the database, 503 otherwise. Unauthenticated.
POST /register Start an account and email the address. Always 202 with no body, so it can't reveal which addresses are registered; a taken address gets a different email instead. 400 captcha when Turnstile is on and its token fails. 5/minute.
POST /token Exchange email + password for a JWT (form-encoded; email goes in username). 403 email_not_verified, after the password checks out, until the address is confirmed. 5/minute.
GET /me The logged-in user — used to check a stored token is still valid. Includes email_verified and currency.
PATCH /me Change settings: {currency}, the currency every total is shown in and new subscriptions start in.
GET /rates ECB reference rates for the currencies on your subscriptions, for converting per-item figures the way the summaries do. Empty when everything is in your own currency.
PUT /me/password Change password, given the current one. Signs out every other session and returns a fresh JWT.
DELETE /me Delete the account and everything in it, given the password. No undo.
POST /verification Email a fresh confirmation link, given {email, password}. Sends only when the pair is right and the address unconfirmed, but always answers 204. 5/hour.
POST /verify-email Confirm the address in a ?verify= link's token. Unauthenticated; 400 expired/invalid for a bad token.
POST /password-reset Email a reset link if the address has an account. Always 202, so it can't reveal which addresses are registered. Behind Turnstile when it's on. 5/hour.
POST /password-reset/confirm Set a new password from a ?reset= link's token and return a fresh JWT. Each link works once, for 1 hour; using it signs out every other session and confirms the address.
GET /subscriptions List, with optional category, billing_cycle, status, active filters. Each row includes paid_total, what that run has been billed so far in its own currency, and paid_total_converted, the same in yours.
POST /subscriptions Create one. currency defaults to yours; it can't be changed afterwards.
GET /subscriptions/upcoming What is about to be charged: every renewal in the next days (default 30), with the full amount due on each day, plus any trial converting in the window.
GET /subscriptions/{id} Fetch one.
PUT /subscriptions/{id} Partial update — send only the fields that change. A cancelled run cannot move to another status in place; use /restore.
POST /subscriptions/{id}/archive Hide a cancelled subscription from the main list. Only for cancelled rows; /unarchive undoes it.
POST /subscriptions/{id}/restore Reactivate a cancelled service as a linked new run. Optional cost, currency, billing_cycle, started_date and next_renewal_date set its new terms. The old run and its spend stay intact.
DELETE /subscriptions/{id} Delete one.
GET /subscriptions/summary/monthly-total What is being paid now: active subscriptions normalised to a monthly figure, plus the yearly equivalent.
GET /subscriptions/summary/spend What a period cost: month-by-month breakdown for a year, each charge counted in the month it was taken, stopped plans included up to the day they stopped. Converted into your currency, with a per-currency by_currency breakdown.
GET /categories Categories with a count of the subscriptions using each.
POST /categories Add an empty category.
PUT /categories/{id} Rename, relabelling every subscription using the old name.
DELETE /categories/{id} Delete; needs reassign_to=<id> or detach=true if still in use.
GET /export The whole account as one file — ?format=json (the backup) or ?format=csv (the spreadsheet version).
POST /import Read such a document back — ?mode=merge (the default) or ?mode=replace for a true restore.

Every total is in the user's currency. A charge in another currency is converted at the European Central Bank reference rate of its own day (the latest rate before it, for a weekend), and a charge still to come at the latest rate; each is rounded to the cent before it is added, so by_currency always sums to the total. Rates are fetched from Frankfurter on demand and cached in Postgres (app/fx.py). If the source can't be reached, stored rates are used and the response says how old they are (rates_as_of, rates_stale).

The two summary routes answer genuinely different questions, which is why they are separate endpoints rather than one with a flag: monthly-total counts only what is billing right now, expressed as a rate, so a yearly plan is shown at a twelfth of its price. spend counts money that actually moved, in the month it moved: a yearly plan is one charge for its full price in the month it renews, and a plan cancelled or paused in June keeps every charge taken up to that day. A €149.99 yearly plan taken out in October 2025 therefore reads as €149.99 in 2025, not the €37.50 an amortized figure would leave in the year.

upcoming is a third question again — the same arithmetic as spend, pointed forwards: it lists the charges still to come rather than totalling up the ones already made. A monthly plan appears once per renewal, so it is listed three times in ?days=90.

Subscription statuses

A subscription is active, trial, paused or cancelled. The distinction that matters is that three of those do not bill, and they do not mean the same thing:

  • trial is running and free. It counts toward no total, converts once on its renewal date — which is therefore never rolled forward — and appears in upcoming on that day at a cost of 0, with the real price in the nested subscription so a client can say "then €9.99/mo".
  • paused has stopped billing but is expected back. What it cost before the pause is still real history: paused_date records when it stopped, and spend counts every month up to it. Without that date a pause would retroactively erase a year of spend.
  • cancelled has stopped for good, with cancelled_date playing the same role — and a yearly plan keeps counting to the end of the term already paid for. Pausing and then cancelling keeps the pause date, because that is when the money actually stopped.

Reactivating a cancelled service creates a new run. Its cost and cycle can change without recalculating the paid run's past charges. The first charge defaults to the later of today and the old run's paid-through date; it can be chosen explicitly. A future run is excluded from the current monthly total until its start date. A group with a current run cannot be reactivated again.

The older active boolean still works, on the way in and the way out: it maps to active-or-cancelled exactly as it always did, so trial and paused both report false. Sending status and active together is fine when they agree and a 422 when they contradict each other — guessing which half was meant would silently cancel a subscription or silently revive one. As a filter, active=false means every status that does not bill; use status=cancelled to ask for cancelled rows specifically.

Renewal dates

next_renewal_date is written like a date and read like a schedule. What a client sends is the anchor of the upcoming renewal schedule; for a running plan, what comes back is the next renewal derived from it — the first one falling on or after today. Nothing rolls the stored value forward, so it cannot go stale: a subscription added in 2020 and never touched since still reports the right date today, and no scheduled job or write-on-read is involved.

Two consequences worth knowing:

  • Sending a date in the past is fine, and is usually the right thing to do when adding a subscription you have had for a while — it says when the plan started billing.
  • Whole calendar months are used, not 30-day steps, so a plan anchored on the 31st bills on the 28th in February and is back on the 31st in March.

A cancelled subscription is measured from the day it was cancelled rather than from today. When its start date is known, the paid-through date uses the same charge schedule as spend, even if the stored renewal anchor differs. It reports the renewal that would have come next — the day the term already paid for runs out, which is what lets spend count a cancelled yearly plan to the end of that term. It is never a charge still to come: billing is upfront, and cancelling stops it. Measurement starts the day after the plan stopped, because a charge taken on the stopping day itself was still taken — cancelling the moment the money leaves the account buys the whole period it just paid for, not nothing.

Accounts

Every subscription belongs to a user, and every API route except /health, /register, /token and the email-link routes requires a login. Sign up on the frontend, or straight against the API:

curl -X POST localhost:8000/register -H 'Content-Type: application/json' \
  -d '{"email":"you@example.com","password":"at-least-8-chars"}'

Then open the confirmation link from the email — locally, from docker compose logs backend — before signing in: /token refuses an unconfirmed address.

Logging in returns a JWT, which the frontend keeps in localStorage and sends as Authorization: Bearer <token> on every request. Tokens expire after 12 hours; a different browser or device starts logged out. On http://localhost:8000/docs the Authorize button logs the docs page in the same way.

Open signup

Anyone can sign up, so what stands in for the invite code the beta used to have is:

  • A confirmed address before signing in. /token answers 403 email_not_verified until the emailed link is opened, and a token issued before that rule existed stops working too.
  • No answer that says an address is taken. /register and /verification answer the same for every address. The difference goes to the inbox: a verified owner is told the account exists, and an account that was never confirmed gets a link to choose the password, so whoever reads that inbox ends up holding it, not whoever signed up first.
  • Caps on outgoing email (email_sends, keyed by a hash of the address): 5 a day per address, EMAIL_DAILY_CAP in all.
  • Rate limits per client, keyed on the real address behind Caddy (TRUST_FORWARDED_FOR).
  • A bot check on signup and reset, when TURNSTILE_SECRET_KEY and the frontend's site key are set.
  • A ceiling on what one account stores, because the whole app shares one database with 10 GiB of storage: 500 subscriptions and 100 categories (crud.MAX_SUBSCRIPTIONS / MAX_CATEGORIES). A create, restore or import that would go over answers 409 and writes nothing. An import is counted after a replace has emptied the account and after a merge has matched rows, so it is judged on what the account would end up holding. Request bodies over 1 MB get a 413.

Email

A new account gets a confirmation link by email and can't sign in until it is opened; the sign-in screen can send the link again. A forgotten password is reset from a link sent by Forgot your password? on the sign-in screen. That works for unconfirmed accounts too, and completing it confirms the address, since opening the link proves the inbox is yours.

Links carry short-lived signed tokens (backend/app/auth.py) rather than rows in a table: a confirmation link lasts 48 hours, and a reset link 1 hour and only once, because using it bumps the account's token_version.

Locally nothing is sent: EMAIL_BACKEND=console logs each message, link included, so docker compose logs backend is the inbox. Production uses Resend's free tier (3,000 emails/month), which needs a sending domain verified in Resend (SPF/DKIM records, plus a DMARC record), EMAIL_BACKEND=resend, and RESEND_API_KEY, EMAIL_FROM and APP_URL in the server's env file (deploy/README.md § Environment). Leave Resend's open and click tracking off for that domain: the app promises no tracking.

Backup and restore

GET /export returns everything in the logged-in account as one file — its subscriptions and its category list, including categories nothing uses yet. Both formats carry a Content-Disposition filename, so curl -OJ lands a dated file on disk without naming it:

curl -OJ localhost:8000/export -H "Authorization: Bearer $TOKEN"
curl -OJ 'localhost:8000/export?format=csv' -H "Authorization: Bearer $TOKEN"

JSON is the backup and CSV is the interchange format. A JSON round trip reproduces the account exactly. A CSV is one row per subscription, which a spreadsheet opens and a person can hand-edit — and which has nowhere to keep a category nothing is using, or the file's version stamp. Its columns are name,category,status,cycle,cost,next_renewal (the order the design handoff pins) followed by started_date,cancelled_date,paused_date: without those three, re-importing an export would silently rewrite when each subscription started and stopped, and every past month in the spend summary with it.

POST /import reads a file back. It takes JSON only — the frontend parses a dropped .csv into this shape itself, because it has to read the file anyway to show the summary before anything is written. The file carries no ids, no email and no password hash, so a backup restores into a fresh account just as well as the one it came from:

curl -X POST 'localhost:8000/import?mode=merge' -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d @backup.json

mode=merge is the default because it is the one that cannot delete anything. A row whose name matches one already in the account (trimmed, case-insensitive) updates that subscription; a row with no counterpart is added. So importing the same file twice is a no-op, and importing an edited file does what it looks like it does — which is the whole workflow this exists for: export, edit the file, read it back. Two rows of the same name are paired off in order against two subscriptions of that name, because a user really can have two Netflix accounts.

mode=replace is the true restore: it deletes the account's existing subscriptions and categories first, so the account ends up matching the file exactly. Either way the import is one transaction; a file that fails part way through leaves the account untouched. The response says what happened:

{"mode":"merge","subscriptions_imported":1,"subscriptions_updated":1,
 "subscriptions_unchanged":10,"subscriptions_removed":0,"categories_imported":1}

?replace=true is still accepted as the older spelling of mode. Sending both is a 422 unless they agree — guessing which half of a contradiction the caller meant would empty an account on a coin flip.

The file carries a version field. A file whose version this build doesn't read is refused with a 400 rather than imported on a guess.

The whole database is backed up by UpCloud, which can restore it to any point in the last 3 days. A restore goes into a new database service, is checked against production with deploy/bin/verify-copy.sh, and only then takes over; the drill took about 17 minutes. The steps are in deploy/README.md § Restore. There is no copy outside UpCloud, so a deletion noticed after 3 days can't be undone that way, which is what the per-account JSON export is for.

Tests

cd backend
pip install -r requirements-dev.txt
pytest

254 tests across 21 files, no services required: conftest.py points the app at a throwaway SQLite file, so a clean checkout can run the suite with nothing else started. To run the identical suite against real Postgres — the one place the two databases differ is Numeric, which comes back as a Decimal from Postgres and a float from SQLite:

docker compose up -d db
docker compose exec db psql -U postgres -c "CREATE DATABASE subscriptions_test;"
TEST_DATABASE_URL=postgresql+psycopg://postgres:devpassword@db:5432/subscriptions_test pytest

What they cover, and why those things:

File What it pins down
test_renewals.py The date arithmetic, directly: month-end clamping that does not stick, leap days, and the property that a derived renewal is never in the past.
test_isolation.py That one user cannot read, edit or delete another's rows — as a 404, not a 403, which would confirm the id exists.
test_spend.py The spend arithmetic: a monthly plan cancelled in June, a yearly plan cancelled the day after renewing, and rows with unknown dates.
test_subscriptions.py The derived renewal date through the API, the cancellation bookkeeping, and every validation rule that has already reached the database once.
test_upcoming.py The window, one entry per charge, and what is deliberately left out.
test_status.py The four subscription statuses: that a pause stops the spend without erasing the months already billed, that a trial costs nothing and converts once, and that the legacy active boolean still means what it always meant.
test_migrations.py That the revisions and models.py describe the same schema, that a downgrade leaves nothing behind, and — since revision 0002 — what a data migration does to the rows themselves.
test_restore.py, test_archive.py, test_paid_total.py Reactivating a cancelled service as a new run, archiving, and what each run has cost to date.
test_account.py, test_email_links.py, test_signup.py Change password, delete account, the verify and reset links, and what open signup relies on: confirm before sign-in, no enumeration, email caps and the bot check.
test_rate_limit.py, test_races.py, test_cors.py, test_health.py, test_logging.py, test_backup.py, test_categories.py The rest of the HTTP surface: limits, concurrent writes, allowed origins, the healthcheck, request logging, import and export, and categories.

The frontend has a Playwright visual suite instead: it screenshots the app against fixed fixture data, with no backend, on mobile and desktop, and fails on any pixel diff. See frontend/tests/visual/README.md:

cd frontend
npm run test:visual

Two conventions worth keeping if you add more: no test may depend on what today's date is (the spend tests all use a year fully in the past), and tests call the API over HTTP rather than crud.py directly, because the status code is as much a part of the contract as the body.

CI runs the backend suite on every push to main and every pull request, against both databases — see test.yml. The badge at the top of this README is that workflow's result on main; it is scoped to branch=main&event=push so an in-flight pull request cannot turn it red.

It goes red if either database leg fails, which is the point of running both.

Deliberate simplifications

Things a production app would do differently, listed so they read as choices rather than oversights:

  • The frontend has visual tests only. The Playwright suite catches layout changes, but there are no unit tests for its logic, and nothing runs the frontend against the real backend.
  • No per-token revocation. Logout is client-side only. A password change signs out every session at once, but nothing can revoke one token on its own.
  • Postgres runs as the superuser locally with a password from .env.
  • Some of the dashboard is computed client-side: the charges in an arbitrary month (the upcoming route only looks forward from today), and the per-category split for a period. TODO.md's D2 and D3 record why that was kept rather than moved to the API.
  • No currency anywhere in the schema. cost is a bare Numeric(10, 2) and the frontend hardcodes EUR, which is an unstated assumption rather than a decision anyone made.

Project layout

backend/
  Dockerfile          # python:3.13-slim, deps cached before app code
  requirements.txt
  requirements-dev.txt # test-only deps; never installed into the image
  pytest.ini          # so `pytest` alone works from backend/
  entrypoint.sh       # runs `alembic upgrade head`, then execs the app
  alembic.ini         # migration config; the URL comes from DATABASE_URL
  alembic/            # env.py + versions/ — the schema's history
  tests/              # see Tests above
  app/
    main.py           # FastAPI app: routes, status codes, CORS, rate limits, OpenAPI metadata
    backup_csv.py     # the CSV side of GET /export (JSON is the real backup)
    auth.py           # bcrypt hashing, JWT mint/verify, get_current_user dependency
    mailer.py         # send_email: console in dev, Resend in production
    cache.py          # Redis cache-aside for the list reads; fails open
    renewals.py       # the renewal date arithmetic
    logging_config.py # structured request logging
    schemas.py        # Pydantic: the API contract (validation + serialization)
    crud.py           # every database query, all scoped by user_id
    models.py         # SQLAlchemy tables — source of truth for the schema
    database.py       # engine, session factory, get_db dependency
docs/
  index.html          # Redoc page for the published API reference
  mocks/              # standalone design proposals for open issues
.github/workflows/
  test.yml            # pytest on SQLite and Postgres, on push and PR
  frontend-visual.yml # Playwright visual regression, on push and PR
  build-and-push.yml  # builds and publishes both images to GHCR, deploys to UpCloud
  docs.yml            # publishes the API reference to GitHub Pages
frontend/
  ui.html             # entry page for the clickable current UI mock
  Dockerfile          # multi-stage: Node builds, Nginx serves
  docker-entrypoint.sh # writes config.js with API_URL at container start
  tests/visual/       # Playwright specs, fixtures and baseline screenshots
  src/
    App.jsx           # auth gate, data loading, and the page-level error states
    api.js            # the only module that talks to the backend
    backup.js         # parses an import file and diffs it against the page
    Login.jsx         # register / log in / forgot password; also the re-auth dialog
    ResetPassword.jsx # the ?reset= link's "Set a new password" card
    AccountDialog.jsx # language, email status, change password, delete account
    EmailStrip.jsx    # the unconfirmed-email nudge and link results
    i18n.js, locales/ # the string lookup, and the English and Finnish strings
    modernist.css     # the design system's tokens and component classes
    dashboard.css     # layout for the dashboard, built from those tokens
    format.js         # money, dates, and the period range
    renewals.js       # client-side mirror of backend/app/renewals.py
    services.js       # brand tiles and the quick-add catalogue
    icons.jsx         # the four Lucide glyphs the design uses
    MonoTile.jsx      # the 20px brand square
    dashboard/        # Hero, TrendStrip, KpiBand, CategoryBars, ComingUp,
                      # NextCharge, TrialBanner, SubscriptionTable, AddForm,
                      # ImportExport, dialogs
deploy/
  compose.prod.yml    # production: Caddy + backend + frontend, no db, no redis
  Caddyfile           # TLS, reverse proxy, maintenance switch
  cloud-init.yaml     # the server's first-boot setup
  bin/                # deploy.sh, ops-check.py, verify-copy.sh
  systemd/            # timers for the alerts and the weekly image prune
  tests/              # the deploy-config job's tests
  README.md           # runbooks: provision, deploy, restore, cutover, take down
docker-compose.yml    # db + redis + backend + frontend for local dev
.env.example          # every variable the stack reads
PLAN.md               # project plan and milestones
TODO.md               # open follow-ups and decisions, plus what got fixed
DESIGN.md, PRODUCT.md # the design system and the product's positioning

About

Track recurring subscriptions and what they cost. A heavily commented full-stack learning project: FastAPI + PostgreSQL + React, containerised with Docker Compose and built by GitHub Actions.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages