Contributing to Hardcount
Hardcount is a billing system you run yourself, written in Go on PostgreSQL. It counts what your customers use, works out each bill, makes the invoice, and charges the card through Stripe.
This page takes you from a fresh clone to a pull request that passes CI. It shows how to run Hardcount from source, where the code goes, and which checks CI runs on your change.
Development setup
Section titled “Development setup”You need Go 1.26 or newer and Docker. Node 22 or newer is needed only for
dashboard work, and jq only for scripts/demo.sh.
git clone https://github.com/gethardcount/hardcount.gitcd hardcountcp .env.example .env-
Start Postgres.
Terminal window docker compose up -d postgresYou see:
docker compose pslists thepostgresservice ashealthyafter a few seconds. -
Create your first account and its keys. This also runs the database migrations.
Terminal window make bootstrapYou see: a “Hardcount Bootstrap Complete” box with the dashboard sign-in (email
admin@hardcount.localunless you setHARDCOUNT_BOOTSTRAP_EMAIL), a generated password, and three API keys. Save the password: it is shown only once. Copy the key under “Secret Key — TEST mode” (hc_secret_test_…). -
Start the API.
make devreads.env, which points at the local Postgres and runs migrations on boot.Terminal window make devYou see: a JSON log line with
"msg":"listening"and"addr":":8080".curl http://localhost:8080/healthreturns{"status":"ok"}. -
Start the dashboard, in a second terminal.
Terminal window cd web-v2 && npm install && npm run devYou see: Vite prints
http://localhost:5173/. Openhttp://localhost:5173/loginand sign in with the email and password from step 2. The dashboard opens in test mode, where nothing charges real money. -
Bill a test customer end to end, in a third terminal. This is the golden path: the one flow CI also runs on every PR, from loading prices to a finished invoice.
Terminal window ./scripts/demo.sh hc_secret_test_... # the key from step 2You see: nine numbered steps, each with a green
✓line, then “Done: every step above ran and was checked” and two dashboard links, one to the invoice and one to the customer.
Run the tests with Postgres still up:
go test ./... -race -short -count=1 # unit tests (CI runs -race too)go test -p 1 ./... -count=1 -short=false # unit + integration testsTo try a release without Go or Node, use deploy/compose/quickstart.sh
instead (see the README).
Local services
Section titled “Local services”docker compose up -d postgres redis mailpit starts everything the API can
use on your machine:
| Service | Where | Notes |
|---|---|---|
| Postgres | :5432 |
Password hardcount. Data is kept in a Docker volume. |
| Redis | :6379 |
Used for rate limiting. Optional outside production: without it, only sign-in (/v1/auth) is rate-limited. |
| Mailpit | :1025 (SMTP), :8025 (web) |
Catches every email Hardcount sends, so nothing leaves your machine. |
| The API | :8080 |
Started by make dev. Not a container. |
make bootstrap takes an optional owner email and password:
HARDCOUNT_BOOTSTRAP_EMAIL=you@example.com HARDCOUNT_BOOTSTRAP_PASSWORD=change-me-please1 make bootstrap.
A password needs at least 12 characters, and Hardcount refuses common passwords
from leaked-password lists.
Add a second account
Section titled “Add a second account”One Hardcount server can hold many accounts (tenants), each with its own data, dashboard users and API keys. To add another one, run the bootstrap tool again with a different owner email:
make bootstrap HARDCOUNT_BOOTSTRAP_EMAIL=tenant-b@hardcount.local \ HARDCOUNT_BOOTSTRAP_PASSWORD='choose-a-password' \ HARDCOUNT_BOOTSTRAP_TENANT='Tenant B'The tool runs from source, so it needs Go and this repository. It is not in the Docker image. There is no button for this in the dashboard, and a secret key cannot do it: only a platform key can manage accounts, and a standard Hardcount server issues none.
Several worktrees
Section titled “Several worktrees”Never git stash in a repo shared by several worktrees. The stash belongs
to the whole repo, and a pop can land another worktree’s work in your
tree.
Without make
Section titled “Without make”These are the same steps without make. make dev reads .env, which sets
DATABASE_URL and RUN_MIGRATIONS_ON_BOOT=true. Here you set them on the
command line:
docker compose up -d postgresDATABASE_URL="postgres://hardcount:hardcount@localhost:5432/hardcount?sslmode=disable" go run ./cmd/hardcount-bootstrapDATABASE_URL="postgres://hardcount:hardcount@localhost:5432/hardcount?sslmode=disable" RUN_MIGRATIONS_ON_BOOT=true go run ./cmd/hardcountTo run only the migrations:
DATABASE_URL="postgres://hardcount:hardcount@localhost:5432/hardcount?sslmode=disable" go run ./cmd/hardcount migrateRun your change in the Compose stack
Section titled “Run your change in the Compose stack”This builds the API image from your checkout and runs it in the same stack as a one-VM install:
docker build -t hardcount:local . # from the repo rootcd deploy/composecp .env.example .env$EDITOR .env # set POSTGRES_PASSWORD, HARDCOUNT_APP_DB_PASSWORD, HARDCOUNT_ENCRYPTION_KEY, HARDCOUNT_BOOTSTRAP_TOKENHARDCOUNT_IMAGE=hardcount:local docker compose up -dYou see: docker compose logs -f hardcount-api prints a log line with
"msg":"listening" and "addr":":8080" when the API is ready.
To build the image and run it on its own, against a Postgres you already have:
docker build -t hardcount:latest .docker run --rm -e DATABASE_URL="..." -p 8080:8080 hardcount:latestProject structure
Section titled “Project structure”Each billing domain (invoice, credit, customer, and so on) is a
self-contained package under internal/. Architecture
shows how the packages work together.
internal/{domain}/ store.go — Store interface (what the domain needs from persistence) postgres.go — PostgreSQL implementation of the store service.go — Business logic (validates, orchestrates) handler.go — HTTP handlers (decodes request, calls service, writes response) *_test.go — Unit tests (in-memory store) + integration tests (real Postgres)Rules:
- One domain package never calls another domain’s
ServiceorStoredirectly. A package may import shared value types, cross-cutting infrastructure (auth,audit,session), and the small interfaces of thebillingcoordinator. The allowlist ininternal/arch/boundaries_test.goenforces this: a new cross-domain import fails the test until it is added there on purpose. - Every handler uses
respond.JSON()/respond.FromError()for responses. - Most store methods run inside a transaction scoped to one tenant by
Postgres Row-Level Security (RLS):
db.BeginTx(ctx, postgres.TxTenant, tenantID). A tenant is one account’s data in a shared database; RLS hides every other tenant’s rows. - Some store methods run under
postgres.TxBypass, where RLS does not apply: work across tenants, work before the tenant is known, and two signed-in areas. Such a query must filter bytenant_iditself. Architecture lists them. A new use failsinternal/arch/txbypass_allowlist_test.gountil it is listed there. - Integration tests use
testutil.SetupTestDB().
Adding a new domain
Section titled “Adding a new domain”- Create
internal/{domain}/store.gowith the store interface. - Create
internal/{domain}/postgres.goimplementing the store. - Create
internal/{domain}/service.gowith business logic. - Create
internal/{domain}/handler.gowith HTTP handlers using therespondpackage. - Add a migration with
make migration name=..., then follow Writing a database migration. - Wire it into
internal/api/router.go. - Write tests at the layers How we test names for your change.
Code style
Section titled “Code style”go fmtandgo vetmust pass.- No exported types without doc comments.
- Errors use
errs.DomainErrorfor domain-specific errors orfmt.Errorffor validation. - JSON field names are
snake_case. - ID format:
hc_{type}_{random_hex}(for examplehc_cus_abc123).
Frontend (web-v2)
Section titled “Frontend (web-v2)”The dashboard is a React + TypeScript + Vite app. See web-v2 — the Hardcount dashboard for setup, the dev server, and the directory’s own CI checks. Two things often surprise first-time contributors:
npm installrelies onweb-v2/.npmrc. That file tolerates a peer-dependency mismatch on purpose, so do not “fix” the mismatch.- Typechecking is
npx tsc -b. Do not usetsc --noEmit: here it checks nothing and reports no error.
What CI runs on your PR
Section titled “What CI runs on your PR”CI runs every check below on every PR. Run the ones your change touches locally before you push. Fork PRs get the same run, because no check uses secrets.
Required to merge. A PR cannot merge until each of these passes.
| CI job | What it checks | Local command |
|---|---|---|
unit |
Unit tests with the race detector, then go vet. Includes the source-rule tests in internal/arch |
go test ./... -race -short -count=1 |
test |
Build, module checksums, and the integration tests against a real Postgres | go test -p 1 ./... -count=1 -short=false (needs docker compose up -d postgres; test databases are created for you) |
test-pgbouncer |
The same suite through PgBouncer, a connection pooler, in transaction mode | scripts/pgbouncer-test.sh (needs Docker) |
golden-path |
Boots a real server, runs scripts/demo.sh from prices to a finished invoice, then runs hardcount-doctor over the rows it wrote |
Steps 2 to 5 of Development setup, then go run ./cmd/hardcount-doctor |
lint |
golangci-lint (.golangci.yml), the Go version in go.mod against the Dockerfile, the quickstart release against the Compose template, and govulncheck |
make lint, then govulncheck ./... |
codegen-drift |
Generated Go and TypeScript match api/openapi.yaml |
make gen && git diff --exit-code |
frontend |
Dashboard build (tsc -b and Vite), unit tests, eslint, and knip (unused files, exports and dependencies) |
cd web-v2 && npm run build && npm test && npm run lint |
Also run, not required to merge.
| CI job | What it checks | Local command |
|---|---|---|
e2e |
Playwright tests against a real API and dashboard. Setup: Browser tests (Playwright) | cd web-v2 && npm run test:e2e |
doc-links |
Every internal Markdown link and #anchor resolves. It turns red on a broken link; fix it before merging |
lychee --offline --include-fragments '**/*.md' |
doc-style |
Vale prose lint on the lines your PR adds. It comments only and never fails | vale <changed .md files> |
Notes on three of them:
- hardcount-doctor is a read-only command that scans a database for states
no correct code could produce, such as invoice totals that do not add up.
Point
DATABASE_URLat the owner connection: under the app’s RLS role it sees no rows and reports clean. govulncheckcan fail when a new Go vulnerability is published, even if your change is fine. Say so in the PR and we will handle it.- Lint waivers and source rules (the
//nolint:forbidigoreasons and the//tz:okdate rule) are described in How we test.
What we accept
Section titled “What we accept”Anyone can contribute. We judge the change, not the person. To keep review time for real work, every pull request meets this bar:
- Start with an issue. For anything bigger than a typo or a broken link, open an issue first, or comment on an existing one. Wait until a maintainer agrees with the approach, then link that issue in your pull request. A pull request without an agreed issue is closed without review.
- Some areas are maintainer-only. A change in these areas needs an
agreed issue that says an outside pull request is welcome:
- money and state machines: invoices, payments, credits, dunning, subscriptions and tax;
- database migrations;
- sign-in, sessions and API keys;
- CI and release files (
.github/); - dependencies (
go.mod,web-v2/package.jsonand the lock files).
- One change per pull request, with its tests and docs, and CI green. Unrelated edits, such as formatting sweeps, renames or bulk dependency bumps, are closed.
- Show that you ran it. Say what you changed, why, and how you checked it. A pull request that was not run or understood is closed.
What happens next: CI on a pull request from outside the project waits until a maintainer has read the diff and approved the run. We answer within two days. A pull request below the bar gets one short comment and is closed; you are welcome to open an issue and try again.
Pull requests
Section titled “Pull requests”- Fork the repo and create a feature branch.
- Write tests for new behavior, at the layer How we test names for your change.
- Run the checks above that your change touches (at least the two test commands).
- Update the docs in the same PR. Every user-visible change gets a
CHANGELOG.mdentry, and every UI change is covered by a Playwright spec inweb-v2/e2e/. A doc that no longer matches the code is a bug. For how to write docs, see the writing style guide. - Open the PR with a clear description of what changed and why.
Touching money or a state machine? This covers invoices, payments, credits, dunning (retrying a failed card payment and emailing the customer), subscriptions and tax. Follow the Money-path robustness playbook:
- Before opening the PR, work through its implementation checklist (§3).
- In the PR description, list the state’s site-set (§2): every place in the code that writes that state or reacts to it.
- A guard against a race or a broken money rule needs an automated test. The test forces the race, and you show it fails once the guard is removed (§5).
Words used here that you have not met are in the glossary.