A multi-tenant subscription cost tracker built with FastHTML and SQLite. Track recurring spend across teams, with role-based access control, period-aware price history, soft-deletes with restore, a tamper-resistant audit log, and CSV/JSON import-export — all server-rendered, no JavaScript build step.
- Spend dashboard — what you actually pay this month vs last month (real billing dates, plus what is still due), historical spend for any calendar year (every payment actually charged, at the price in effect on its billing date — each monthly bar matches the "this month" figure for that month), current run-rate, year-over-year delta, per-period cost cards, and bar/breakdown charts by subscription, category, and billing frequency. Every chart mark shows its exact value on hover; click or tap one to pin the tooltip.
- Subscription detail — a summary header (status, current price, next payment)
over four tabs: Overview (details, cost per period, next payments), Spend (monthly
chart and lifetime figures), Periods (price history + add a period), and History
(audit trail). Tabs are plain links (
?tab=…), so they deep-link and survive reloads. - Flexible cadences — daily / weekly / monthly / quarterly / yearly presets,
plus a
custom"every N units" option. - Price periods — each subscription owns one or more dated, non-overlapping active windows, each with its own price; the dashboard and next-payment forecast honour them exactly. Periods that run back to back are one continuous subscription, so a price change keeps the original billing day; a gap between periods (cancelled, then resubscribed) starts a new billing cycle on the new start date.
- Combine duplicates — fold one subscription into another: its periods move across (keeping who added them and when) and an open-ended period is closed the day before the next one starts, then the duplicate is soft-deleted. Import offers this whenever a file brings a name the team already uses — the usual case being newer prices for a subscription whose current period is still open — and the detail page offers it for any same-named leftovers.
- Multi-tenant — subscriptions are owned by a team; users switch between the teams they belong to, and a super-admin can view across all teams at once.
- RBAC — a global role axis (
super_admin) and a per-team role axis (team_admin,viewer) compose by union; the role→permission matrix lives in the database. - Audit log — every create / update / delete / price change / login is recorded; entries are denormalised and FK-free so they survive permanent deletion of the records they describe.
- Soft-delete + restore — deletes are reversible; permitted roles can view, restore, or permanently purge deleted records.
- Import / export — round-trip the active team's subscriptions and price periods as CSV or JSON.
- Dark mode — system-aware theme toggle, no flash on load.
uv sync --group dev # install runtime + test dependencies
uv run python main.py # http://localhost:5001 (live reload)First visit walks you through creating the initial super-admin account.
Lint, type-check, and test (the same checks CI runs):
uv run ruff check app tests # lint + import sorting
uv run mypy # type-check the logic/data layer
uv run pytest --cov # tests with coverageAll configuration is via environment variables:
| Variable | Required | Default | Purpose |
|---|---|---|---|
SUBTRACKER_SECRET |
Prod | random per-process | Session-signing key. Set a stable value in production — without it, logins do not survive a restart and a warning is logged. Generate with openssl rand -hex 32. |
SUBTRACKER_DB |
no | ./subscriptions.db |
SQLite file path. Point at a persistent volume in a container (e.g. /data/subscriptions.db). |
SUBTRACKER_PORT |
no | 5001 |
Listen port. |
SUBTRACKER_SECURE_COOKIES |
no | 0 |
Set to 1 when serving over HTTPS: marks the session cookie Secure and sets SameSite=strict. |
The database runs in WAL mode and is created automatically on first boot.
# 1. Create .env with a stable secret
echo "SUBTRACKER_SECRET=$(openssl rand -hex 32)" > .env
# 2. Build & start
docker compose up -d --build
# 3. Open http://<host>:5001Data persists in the subtracker-data named volume. To surface it inside Home
Assistant, add a Webpage dashboard card (or a Lovelace iframe card) pointing
at http://<host>:5001.
This repo is also a Home Assistant add-on repository exposing two channels:
- SubTracker — stable. Published only when a
v*release tag is pushed. - SubTracker (Nightly) — bleeding edge. Rebuilt on every push to
master, with its own slug, image,/data, and host port (5002) so it runs alongside stable.
The Supervisor pulls a prebuilt image (it does not build on-device), so images are published to GHCR by the GitHub Actions workflows.
One-time publishing setup (maintainer): after each workflow's first successful
run, make its packages public at https://github.com/users/78wesley/packages
(Package settings → Change visibility → Public) — otherwise the Supervisor can't
pull them. The packages are subtracker-amd64 / subtracker-aarch64 (stable) and
subtracker-nightly-amd64 / subtracker-nightly-aarch64.
Installing in Home Assistant (any user):
- Settings → Add-ons → Add-on Store → ⋮ → Repositories → add
https://github.com/78wesley/subtracker. - SubTracker (and SubTracker (Nightly)) appear in the store. Click Install on the one you want.
- On the Configuration tab, optionally set
log_level. (The session key is auto-generated and persisted to/data/.secret— it is not a user option.) - Start, then click Open Web UI.
The SQLite database lives in the add-on's persistent /data directory and is
included in HA snapshots/backups. (Stable and nightly keep separate databases.)
Cutting a stable release (maintainer):
# 1. Bump the version in subtracker/config.yaml, commit it
git commit -am "Release 0.2.0"
# 2. Tag it — this is what triggers the production image build
git tag v0.2.0 && git push origin master v0.2.0release.yaml runs the tests, checks the tag
matches subtracker/config.yaml, builds + pushes ghcr.io/78wesley/subtracker-{arch},
and opens a GitHub Release. Nightly images and the auto-bumped
subtracker-nightly/config.yaml version are handled by
nightly.yaml on every master push (gated on the
test suite). Pushing to master never touches the stable image.
The
Dockerfile/docker-entrypoint.share shared by the image build and docker-compose. The entrypoint reads/data/options.jsonwhen run as an add-on and falls back toSUBTRACKER_*environment variables otherwise.
- Passwords are hashed with bcrypt (per-password salt).
- Sessions are signed cookies keyed by
SUBTRACKER_SECRET. - Authorization is enforced per-route via the RBAC layer (
app/rbac.py,app/authz.py); roles compose by union across a global and a per-team axis. - CSRF protection (
app/csrf.py): a per-session token is required on every state-changing request. It rides in a<meta>tag; client JS attaches it to POST forms (hidden field) and HTMX requests (X-CSRFTokenheader), and a Beforeware rejects any unsafe request whose token doesn't match the session. - Login/setup rate-limiting (
app/ratelimit.py): 10 attempts per 5 minutes per client throttles online password guessing. - CSV export quotes leading formula characters (
=,+,-,@) to neutralise spreadsheet formula injection, and imports are size- and row-capped.
Before exposing to untrusted networks, serve behind HTTPS and set
SUBTRACKER_SECURE_COOKIES=1 — this marks the session cookie Secure (sent only
over HTTPS) and tightens SameSite to strict. The in-memory rate limiter is
per-process; a multi-worker deployment would want a shared store.
app/
main.py ASGI app assembly + global styles/scripts
config.py env-driven configuration
session.py auth gate (Beforeware) + request context
rbac.py / authz.py role catalog, permission resolution, per-route guard
permissions.py permission name catalog (the Perm constants)
csrf.py per-session CSRF token: guard, <meta>, client JS
ratelimit.py in-memory auth rate limiter
auth.py bcrypt hashing + authentication
cost_utils.py cadence math, prorated/period-aware cost, next payments
timeutil.py central date/time provider
styles.py shadcn token CSS + Tailwind utility-class constants
db/ schema (idempotent + migration), data access per entity
routes/ one APIRouter per feature area
components/ FastHTML view helpers (shadcn-styled) + charts
tests/ pytest suite (cost math, RBAC + HTTP enforcement, auth, CSRF,
rate-limit, lifecycle, import/export, migration, HTTP smoke)
Dockerfile production image (used by compose + the GHCR image build)
docker-compose.yml standalone deployment
repository.yaml Home Assistant add-on repository manifest
subtracker/ HA add-on (config.yaml → prebuilt GHCR image, DOCS.md)
.github/workflows/ CI that builds & pushes per-arch images to GHCR