A persistent, Postgres-backed hhq
external-process plugin (see internal/plugins in the hhq repo for the
host-side contract this satisfies). Tracks recurring/one-off bills with a
mark-paid flow, shows upcoming unpaid bills and (optionally) synced bank/card
balances as a two-column kiosk widget, contributes bill due dates as
synthetic calendar events, and offers a settings page for managing bills and
layout, all reachable through hhq's own parent-authenticated dashboard.
It's a separate Go module (its own go.mod) and deployable independently of
hhq, but is built to mirror hhq's own architecture (cmd/server +
internal/{config,db,models,handlers,scheduler} + web/templates).
Bill Tracker keeps its own state in Postgres, in tables prefixed bt_
(bt_bill_definitions, bt_bill_instances, bt_accounts,
bt_simplefin_connection, bt_settings). It's common to point this at the
same Postgres database hhq itself uses (see .env), but this app never
reads hhq's own tables directly - that's not guaranteed to be true in every
deployment, and the two apps' migration histories are kept fully separate
(goose's tracking table here is bt_goose_db_version, not the default
goose_db_version, specifically to avoid colliding with hhq's own migration
tracking when sharing a database).
go run ./cmd/server
No CLI flags - everything is environment-driven, read the same way as hhq
itself: KEY_FILE (a path to a file holding the value, for Kubernetes
Secret-mounted config) takes precedence over the plain KEY env var, which
takes precedence over a built-in default.
| Env var | Default | Purpose |
|---|---|---|
LISTEN_ADDR |
:8090 |
HTTP listen address |
DB_HOST |
(required) | Postgres host |
DB_PORT |
5432 |
Postgres port |
DB_NAME |
(required) | Postgres database name |
DB_USER |
(required) | Postgres user |
DB_PASSWORD |
Postgres password | |
DB_SSLMODE |
disable |
Postgres SSL mode |
CONFIG_DIR |
./.config |
Directory scanned for bills.json on startup |
ENCRYPTION_KEY |
(required) | Hex-encoded 32-byte AES-256 key. Used for SimpleFIN access URLs, and to encrypt the shared token this plugin self-issues to hhq (see "Authenticating hhq" below). Generate with openssl rand -hex 32 |
PLUGIN_CONNECTION_SECRET |
hhq-plugin-connection |
Shared secret hhq must present on POST /register (see "Authenticating hhq" below). Set to the same value as hhq's own PLUGIN_CONNECTION_SECRET if you override it |
BILL_INSTANCE_LOOKAHEAD_DAYS |
60 |
How far ahead recurring bill instances are generated |
SIMPLEFIN_REFRESH_INTERVAL_MINUTES |
60 |
How often account balances are re-fetched from SimpleFIN |
VERSION_CHECK_INTERVAL_MINUTES |
1440 |
How often this plugin checks its own GitHub repo for a newer version, reported via GET /version (see the endpoint table below) |
LOG_LEVEL |
info |
debug/info/warn/error |
LOG_FORMAT |
text |
text or json - json emits one JSON object per log line (time/level/msg), useful for log aggregators like Loki/Grafana |
Bills can be defined two ways, and both persist to the same database:
CONFIG_DIR/bills.json- a JSON array, reconciled against the database on every startup (seebills.json.example). Each entry:name,amount(dollars),schedule("monthly"+day_of_month;"quarterly"+day_of_month+quarter_start_month-1for Jan/Apr/Jul/Oct,2for Feb/May/Aug/Nov,3for Mar/Jun/Sep/Dec; or"one_off"+one_off_dateasYYYY-MM-DD), optionalvendor_url. Bills created this way are marked "bills.json managed" on the settings page and can't be edited/deleted there (edit the file and restart instead) - removing an entry from the file deletes that bill (and its history) from the database on the next restart. A bill with the same name already created through the settings UI is left untouched (not overwritten) if it collides with abills.jsonentry. A vendor-managed entry'spasswordcan instead be given aspassword_file(a path to a file containing just the password, e.g. a Kubernetes Secret volume mount) sobills.jsonitself can live in a plain ConfigMap while individual passwords stay in per-secret files - setting bothpasswordandpassword_fileon the same entry is a startup-time bootstrap error. Either way the password is encrypted before it's stored.- The settings page - add/edit/delete bills directly; mark the current cycle's instance paid there too.
Either way, a bill's schedule (monthly-on-a-day, quarterly-on-a-day within a chosen 3-month rotation, or one-off) is separate from its instances - each due-date occurrence gets its own row so "mark paid" only affects that cycle, not future ones. Instances are generated automatically (a background job, plus immediately after adding/editing a bill so you don't have to wait).
Optional. SimpleFIN Bridge connects to your bank/card accounts and can also be a source of bill amounts for accounts like credit cards (not yet implemented here - see Known Gaps).
To connect: get a one-time setup token from your SimpleFIN Bridge provider,
then paste it into the "SimpleFIN Bridge" section of this plugin's settings
page (reached through hhq's parent dashboard). The plugin exchanges it for a
permanent access URL, encrypts it with ENCRYPTION_KEY, and stores it - the
setup token itself is single-use and not retained. Balances refresh on a
timer (SIMPLEFIN_REFRESH_INTERVAL_MINUTES) or on demand via "Refresh now".
Individual accounts can be hidden from the kiosk balances panel without
disconnecting entirely.
The kiosk widget shows two columns (bills, balances) as a single hhq plugin widget block. Both hhq's own placement of that block (how many of hhq's 12 grid columns it spans, and its sort position among other plugin widgets) and which bill fields show (and in what order) in the bill table are configurable from the settings page's "Kiosk widget layout" section - no redeploy needed.
Copy plugins.json.example to plugins.json in whatever directory hhq's
own CONFIG_DIR env var points at, adjusting base_url to wherever this
plugin is reachable from hhq:
[
{
"id": "bill-tracker",
"name": "Bill Tracker",
"base_url": "http://localhost:8090",
"enabled": true
}
]Restart hhq (or wait for its next PLUGIN_SYNC_INTERVAL_MINUTES tick) and
the widget should appear on the kiosk, "Bill Tracker" should appear on the
parent dashboard's Plugins card, and unpaid bills' due dates should show up
as calendar events.
| Endpoint | Method | Purpose |
|---|---|---|
/register |
POST | Self-registration/re-registration - see "Authenticating hhq" below. Protected by a shared connection secret, not a bearer token; every other endpoint requires the token this issues. |
/manifest |
GET | Static metadata: display name, widget column span (1/2/3) + position (both settings-UI-configurable), whether this plugin provides calendar events |
/widget |
GET | HTML fragment inlined into the kiosk page (fetched server-to-server by hhq, never by the browser directly) |
/events |
GET | ?from=YYYY-MM-DD&to=YYYY-MM-DD - synthetic calendar events (unpaid bill due dates) in that window, as JSON |
/settings |
GET, POST | A full HTML settings page, reverse-proxied through hhq's own parent-authenticated dashboard at /parent/plugins/bill-tracker/settings - this plugin never sees hhq's login/session, hhq only forwards requests here after its own auth check passes. Every form on this page submits to a relative URL so it round-trips correctly through the proxy regardless of the actual path the browser is on. |
/healthz |
GET | Liveness check, any 2xx - unauthenticated, since Kubernetes' probes send no auth header |
/version |
GET | {"version": "1.0.0", "upgradeAvailable": true, "upgradeVersion": "1.0.2", "changelog": "feat: Update versioning", "channel": "dev"} - this plugin's own running version, plus whatever its periodic self-check of its own GitHub repo (VERSION_CHECK_INTERVAL_MINUTES) has found. Unauthenticated, like /healthz - hhq polls it independently of (and before) having a bearer token, and shows an update-available icon on the parent dashboard when upgradeAvailable is true. channel is derived from whether the running version ends in -dev. hhq never talks to GitHub on this plugin's behalf; this endpoint is what makes that possible. |
Every endpoint above except /register, /healthz, and /version requires
Authorization: Bearer <token> on every request (internal/handlers/ auth_middleware.go's RequireBearerToken), rejecting anything else with
403 Forbidden (not 401 - see below for why) - otherwise this plugin's
HTTP port would respond to anyone on the network who found it, not just hhq.
/register itself is gated by a separate shared connection secret, sent
by hhq as an X-Plugin-Connection-Secret header and checked against this
plugin's own PLUGIN_CONNECTION_SECRET env var (default
hhq-plugin-connection, matching hhq's own default - set both to the same
real value if you want one). A mismatch or missing header is rejected with
401 Unauthorized - deliberately different from the 403 the bearer
check above uses: hhq's internal/plugins.Register recognizes 401 from
/register specifically as "the connection secret itself is wrong" (a
standing misconfiguration retrying won't fix) and logs a pointed message
telling the operator to check PLUGIN_CONNECTION_SECRET on both sides,
rather than treating it as an ordinary/transient failure. This plugin also
logs every rejected /register attempt itself (never logging the secret
value) - worth checking these logs first if hhq reports a connection-secret
mismatch. On a valid secret, this plugin generates a fresh token, stores it
encrypted in bt_settings (ENCRYPTION_KEY) - overwriting whatever token
was stored before - and returns it; hhq stores the same value encrypted
in its own database and sends it back on every subsequent request. Unlike
an earlier version of this contract, /register is not restricted to
succeeding only once - a valid secret lets it reissue a token any number of
times, which is what makes automatic recovery (below) possible. If hhq
starts before this plugin is up, it retries /register every 15 seconds
until it succeeds (see hhq's own internal/handlers/plugin_bootstrap.go),
so no particular startup ordering is required.
Recovery if hhq and this plugin ever fall out of sync (e.g. this plugin
was redeployed and lost its stored token, or hhq's response from
/register was lost in transit after this plugin had already stored a
token) is now automatic: any bearer-token check on this plugin's side
rejects with 403 Forbidden specifically so hhq recognizes it as "my
token is no longer valid" and calls POST /register again (with the
connection secret) to get a fresh one, retrying the original request once
- no manual database surgery needed. (A
401would not trigger hhq's recovery - this is why the bearer check above uses403instead of the more conventional401for an invalid/missing token.)
hhq treats a registered plugin's responses (its widget HTML, its settings
page) as trusted content, not sanitized input - they're rendered/embedded
verbatim into hhq's own pages. Only register a plugin you wrote or trust as
much as hhq itself. The /widget and /settings responses are only ever
fetched server-to-server by hhq, so their styling is self-contained inline
<style> rather than a linked stylesheet - in a real deployment this
plugin's base_url may not even be reachable from a parent's browser (e.g.
a cluster-internal Kubernetes Service DNS name), only from hhq itself.
Versions follow MAJOR.MINOR.PATCH, tracked entirely via git tags (no
committed version file). Work happens on dev; every push there is
automatically tagged with the next patch build (e.g. 1.1.4-dev) and
published to GHCR as ghcr.io/mscreations/billtracker-plugin:1.1.4-dev /
ghcr.io/mscreations/billtracker-plugin:latest-dev. Promoting dev to
main is a manual pull request on GitHub; once merged, an Action tags the
next minor release (e.g. 1.2.0), cuts a GitHub Release, publishes
ghcr.io/mscreations/billtracker-plugin:1.2.0 / :latest, and
opens+merges a PR syncing main back into dev so dev picks up the new
line. The plugin reports its running version via GET /manifest, which
hhq's parent dashboard shows in the Plugins card's Version column.
This project was developed with substantial AI assistance (Claude Code). All AI-generated changes were reviewed and tested by the maintainer before being committed. The automated test suite was written entirely by AI, under human review.
- Deriving a bill from a linked SimpleFIN account (e.g. auto-detecting a
credit card's statement balance/due date) isn't implemented - the schema
has a hook for it (
bt_bill_definitions.simplefin_account_id) but no logic yet. - Direct vendor bill-pay isn't implemented - a bill's
vendor_urlis just stored and linked, not integrated with any payment API. - Single replica only - the scheduler (instance generation, SimpleFIN refresh) has no distributed locking.