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.
- 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
-
Copy the example env file:
cp .env.example .env -
Generate a signing key for the auth tokens and put it in
.envasSECRET_KEY(the backend refuses to start without one):python -c "import secrets; print(secrets.token_hex(32))".envis gitignored — the real key never gets committed. -
Start everything:
docker compose up --build -
Open:
- Frontend: http://localhost:5173
- Backend API docs: http://localhost:8000/docs (or the published reference: https://juusimaa.github.io/subscription-tracker)
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
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. |
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.Userhas ahashed_passwordcolumn;schemas.Userhas no such field, so the hash cannot leak through a response even if a handler returns the whole row. Likewise the client may not sendidoruser_idon 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
Nonefrom crud into a 404, and a Pydantic model into a validated 201.
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, everydocker compose downwould 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 explicitdown -v. - The healthcheck. Compose polls
pg_isreadysobackendcan wait forcondition: 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, neverFloat. Binary floating point cannot represent0.10exactly; summing a column of them drifts.Numericmaps to PostgresNUMERICand arrives in Python asDecimal, which is exact.user_idisnullable=Falseand 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.emailisunique=True, so a duplicate signup is rejected by Postgres even if two registrations race past the application's own check.categoriescarries a compositeUniqueConstraint(user_id, name): two users may both have "Music", one user may not have it twice. Subscription.categoryis a plain string, not a foreign key tocategories. 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_categoryis 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 theCMD. Acommand:in docker-compose.yml replacesCMDbut notENTRYPOINT, 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:
EmailStrrejects 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_validatorhandles 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=Truelets 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.
SubscriptionUpdatemakes every field optional soPUTcan carry one field;crudthen usesmodel_dump(exclude_unset=True)so absent fields aren't overwritten withNone. 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 bareDecimalas a JSON string ("15.99"). The summary routes predate having aresponse_modeland emitted a number. KeepingDecimalfor 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_categoriesdoes 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_categorycallsdb.addbut neverdb.commit, so the new category and the subscription that introduced it are saved together or not at all.import_backupis one transaction over the whole file: a file that fails half way leaves the account exactly as it was. And because the session isautoflush=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 definesget_dbas a generator. FastAPI runs it up to theyieldbefore the handler, hands over the session, and runs thefinallyafter 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 theUser, or rejects with 401 before the handler body runs. That is whydelete_subscriptionnever 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, notasync def. That is correct here: SQLAlchemy's synchronousSessionblocks, and FastAPI runs plaindefhandlers in a threadpool. Marking themasyncwithout 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.
checkpwcompares in constant time. SECRET_KEYis 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
subcannot 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.
/tokenfollows the OAuth2 password flow, which fixes the field names asusernameandpassword— so the email goes inusername. Following the spec is what makes the Authorize button on/docsa 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 handles204 No Content(aDELETEhas no body to parse). Every other module calls named functions likegetSubscriptions(). -
The 401 path is centralised. When the backend rejects a token,
request()clears it and dispatches anauth-expiredevent. WhatApp.jsxdoes 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.jsxinitialises its state lazily from it, then calls/meto check the token is still valid before showing the app. -
VITE_API_URLislocalhost:8000, notbackend:8000. Only variables prefixedVITE_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 aVITE_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 —
.csvand.jsonboth become the bodyPOST /importaccepts, 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:localesfails 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/upcomingis 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.
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_URLpoints atdb:5432. This name resolves only between containers. depends_on: condition: service_healthywaits for a real readiness signal, not just process start.- Bind mounts give hot reload.
./backend/app:/app/appmaps your source into the container so uvicorn's--reloadpicks 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
CMDto add--reload, and targets the frontend'sbuildstage 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:CMDis replaced,ENTRYPOINTis 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.
10. CI — .github/workflows/
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-backendghcr.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.
POST /subscriptions, from the click to the row appearing — every layer above,
in order.
- Browser.
createSubscription(data)in api.js builds afetchwithContent-Type: application/jsonandAuthorization: Bearer <token>. Note what the body does not contain: any notion of who owns the row. - CORS preflight. Because the request carries a custom header, the browser
first sends
OPTIONS /subscriptionsand waits.CORSMiddlewareanswers 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.) - uvicorn parses the HTTP and calls the ASGI app.
- Routing. FastAPI matches method + path to
create_subscription. - Dependencies resolve first.
get_dbopens a session and suspends at itsyield.oauth2_schemepulls the bearer token.get_current_userverifies the signature and expiry, then loads theUser— using the same cached session. Any failure here is a 401 and the handler never runs. - Body validation. The JSON is parsed into
SubscriptionCreate:costbecomesDecimal, dates becomedate,billing_cyclebecomes the enum. A bad payload is a 422 nobody had to write. - The handler runs in a threadpool (it is
def) and delegates tocrud.create_subscription, which unpacks the schema into a model, addsuser_idseparately from the token, registers the category, defaultsstarted_dateto today, thenadd→commit→refresh(the refresh loads the database-assignedid). - The response model shapes the output. The returned ORM object is read
through
schemas.Subscription— possible thanks tofrom_attributes=True— which drops anything not on the schema and encodesDecimalanddateto JSON. Status 201. - Back out through CORS middleware, which stamps the
Access-Control-Allow-Originheader the browser requires. - Teardown. FastAPI resumes
get_db; itsfinallycloses the session and returns the connection to the pool. - Frontend parses the JSON and gets the new
id, which React needs as a list key and for laterPUT/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.
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:
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.
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:
trialis running and free. It counts toward no total, converts once on its renewal date — which is therefore never rolled forward — and appears inupcomingon that day at a cost of0, with the real price in the nested subscription so a client can say "then €9.99/mo".pausedhas stopped billing but is expected back. What it cost before the pause is still real history:paused_daterecords when it stopped, andspendcounts every month up to it. Without that date a pause would retroactively erase a year of spend.cancelledhas stopped for good, withcancelled_dateplaying 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.
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.
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.
Anyone can sign up, so what stands in for the invite code the beta used to have is:
- A confirmed address before signing in.
/tokenanswers 403email_not_verifieduntil the emailed link is opened, and a token issued before that rule existed stops working too. - No answer that says an address is taken.
/registerand/verificationanswer 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_CAPin 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_KEYand 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 areplacehas 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.
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.
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.
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.
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
upcomingroute 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.
costis a bareNumeric(10, 2)and the frontend hardcodes EUR, which is an unstated assumption rather than a decision anyone made.
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