A small app for tracking recurring subscriptions (Netflix, HBO, etc.), built as a learning project for Docker, PostgreSQL, and CI/CD to Azure. See PLAN.md for the full project plan and milestones.
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 16
- Auth: JWT bearer tokens, bcrypt-hashed passwords
- Three containers (
db,backend,frontend), orchestrated with Docker Compose for local dev; images published to GHCR by GitHub Actions.
-
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 (db_data). 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. |
SECRET_KEY |
backend |
Signs the JWTs. Changing it logs everyone out. |
VITE_API_URL |
frontend |
Baked into the browser bundle, so it must be an address your browser can reach. |
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 │
│ 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)
▼
┌──────────────────────────────────────────────┐
│ db container — PostgreSQL 16 │
│ volume db_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:16 image, 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
db_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.
Three tables: users, categories, subscriptions. 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 that one origin. 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.
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.
Nothing invalidates an issued token, so the 12-hour expiry is the only thing that ever revokes one; logout is purely client-side. Real revocation needs a token 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 with inline editing, and the import and export panel at the page foot. 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. -
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.
10. CI — .github/workflows/
Three workflows, each triggered by what it actually depends on:
| Workflow | Runs on | Does |
|---|---|---|
test.yml |
pushes and pull requests touching backend/** |
The test suite, twice — against SQLite and against Postgres 16 |
build-and-push.yml |
pushes to main |
Builds both images, publishes them to GHCR |
docs.yml |
pushes to main touching backend/** or docs/** |
Generates openapi.json from the app and publishes the API reference |
test.yml is the only one that also runs on pull requests: a suite that only
runs after a merge reports the problem too late to be worth much. It 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.
Packages are private by default — make them public, or docker login ghcr.io
with a personal access token, to pull them elsewhere. Nothing deploys these yet;
that's milestone 7.
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.
Everything except the first three 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.
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 |
Create an account. |
POST |
/token |
Exchange email + password for a JWT (form-encoded; email goes in username). |
GET |
/me |
The logged-in user — used to check a stored token is still valid. |
GET |
/subscriptions |
List, with optional category, billing_cycle, status, active filters. |
POST |
/subscriptions |
Create one. |
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. |
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. |
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. |
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.
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 the billing schedule is measured from; 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, so 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 all API routes except /health,
/register and /token require 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"}'
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.
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.
cd backend
pip install -r requirements-dev.txt
pytest
143 tests, 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. |
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 all of this on every push and pull request that touches backend/,
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 tests cover the backend only. The suite (see Tests) runs in CI against both databases, but nothing tests the React frontend, and no test drives a browser.
- No token revocation. Logout is client-side only; the 12-hour expiry is the only thing that invalidates a token.
- CORS allows exactly one hardcoded origin, which will need to become configurable before a deployed frontend can call the API.
- Postgres runs as the superuser with a password from
.env; a real deployment would use a least-privilege role and a managed secret store. - Some of the dashboard is computed client-side because the API cannot
answer it yet: the charges in an arbitrary month (the
upcomingroute only looks forward from today), and the per-category split for a period, which costs one request per category instead of one grouped response. Both are written up as D2 and D3 in TODO.md. - 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, 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
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
.github/workflows/
test.yml # pytest on SQLite and Postgres, on push and PR
build-and-push.yml # builds and publishes both images to GHCR
docs.yml # publishes the API reference to GitHub Pages
frontend/
Dockerfile # multi-stage: Node builds, Nginx serves
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; also the re-auth dialog
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,
# TrialBanner, SubscriptionTable, AddForm, ImportExport,
# dialogs
docker-compose.yml # db + backend + frontend for local dev
.env.example # every variable the stack reads
.github/workflows/ # CI: build and push images to GHCR
PLAN.md # project plan and milestones