Project rules at a glance
These are the maintainer’s working rules for this repo, written for the people and coding agents who change its code. To use Hardcount, start with the quick start.
Hardcount is an open-source usage-based billing engine built in Go. It handles pricing, subscriptions, usage metering, invoice generation, Stripe payments, dunning, and customer credits.
Rules at a glance
Section titled “Rules at a glance”Each rule is explained in the section named after it.
- Architecture: a domain never calls another domain’s
Service/Storedirectly.internal/arch/boundaries_test.goenforces this. - Money-path changes: before writing, list every code place that touches the state. Follow the money-path playbook.
- Testing: every change ships with a test at the right layer. UI changes are checked with Playwright and by looking at the screen.
- Concurrent sessions: work in your own git worktree, and check every branch before you claim a migration or ADR number.
- Documentation discipline: every user-visible ship updates its docs in the same PR. The doc must not lie.
Architecture
Section titled “Architecture”- Per-domain packages in
internal/. Each domain owns its store, service and handler, with store interfaces per domain (not a god Repository). - Rule: no cross-domain concrete-service/store coupling between peer business domains. A domain never calls another domain’s
Service/Storedirectly. - Allowed cross-domain imports:
- (a) cross-cutting infra (
auth,audit,session); - (b) shared value types, DTOs and validation helpers (chiefly
tax.*,subscription.ListFilter); - (c) the
billingcoordinator. The billing engine orchestrates peers through narrow consumer-defined interfaces.
- (a) cross-cutting infra (
- A new cross-domain import edge fails
internal/arch/boundaries_test.gountil it is justified in the allowlist. - PostgreSQL with Row-Level Security for tenant isolation on most API requests:
db.BeginTx(ctx, postgres.TxTenant, tenantID).- Some work runs under
postgres.TxBypass, where RLS does not apply, and must filter by tenant itself. Examples: background jobs, sign-in and sessions, public links, incoming webhooks, Stripe credential settings, and team members. The full list is indocs/architecture.md(Design rules). - A new
TxBypassuse failsinternal/arch/txbypass_allowlist_test.gountil it is listed.
- Some work runs under
- chi/v5 for HTTP routing.
- API key auth with 2 stored key types: platform and secret (
api_keys_key_type_check).- Dashboard sessions use a third, in-code type,
KeyTypeSession, with the secret-key permission set (internal/auth/permission.go).
- Dashboard sessions use a third, in-code type,
- HMAC-SHA256 webhook signing (both inbound Stripe and outbound).
Money-path changes
Section titled “Money-path changes”Any change that touches money or a state machine follows Money-path robustness playbook. State machines here are invoices, payments, credits, dunning, subscriptions and tax.
The one rule the playbook enforces: do not reason locally. Before writing, enumerate the state’s complete site-set (every writer, effect-firer, gated reader, caller/callee, crash point).
- Site-set: every code place that touches one state, such as an invoice status.
- Effect-firer: code that sends an email, webhook, Stripe call or ledger write on a change.
- Gated reader: code that branches on the state.
See the glossary for the full entry. Use the playbook at four stages:
| Stage | What the playbook gives you |
|---|---|
| Design | site-set enumeration + adversarial panel |
| Implementation | the 12 gates |
| Review | the per-class lens |
| Tests | collision + real-Postgres + concurrent-resolver fake + mutation-verify |
A concurrent-resolver fake is a test double that commits the racing transition at the external-call boundary, or between the precondition read and the write.
Running locally
Section titled “Running locally”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/hardcountTesting
Section titled “Testing”How we test, which test to write for which change, and how to run each layer: How we test Hardcount.
go test ./... -race -short -count=1 # unit tests only (what CI runs)go test -p 1 ./... -count=1 -short=false # includes integration tests (needs postgres)- Every change ships with a test at the right layer. Money and state changes get a Go integration test against real Postgres.
- UI changes are checked with Playwright (
cd web-v2 && npm run test:e2e) and by looking at a screenshot of the screen. Text and DOM checks cannot see layout bugs. - Live Stripe, email, LiteLLM and PDF checks run by hand before each release: Release checklist: live services.
Concurrent sessions
Section titled “Concurrent sessions”Two or more Claude Code sessions may work this repo at the same time. Rules:
- Every session works in its own git worktree (
.claude/worktrees/<task-name>). The main working tree stays onmain. No session edits it, switches its branch, or stages files there. - Claim a migration or ADR number only after checking origin/main and every local branch (
git worktree list,git branch -a). Another session’s unmerged migration or ADR may already hold the next number. For an ADR, listdocs/adr/on each branch too: a duplicate migration number fails loudly at integration-test time, but a duplicate ADR number fails silently (#196/#197 shipped two050s). - Stay on disjoint domains/packages where possible. CHANGELOG conflicts are expected and cheap: whoever merges second rebases and keeps both sides’ entries.
- Merge small PRs promptly; rebase onto origin/main before push. A conflicting PR gets no CI, because GitHub cannot build the merge ref. Rebase first, then watch checks.
- Shared singletons:
- One local Postgres. Concurrent
-short=falseruns from two sessions can interfere. Run unit tests freely, and treat CI as the integration gate. - One
make dev/ vite (ports 8080/5173).
- One local Postgres. Concurrent
Important decisions
Section titled “Important decisions”- Auth: dashboard uses email + password; API uses Bearer keys. See
docs/adr/011-email-password-auth-and-clean-api-keys.md; ADR-007 and ADR-008 are superseded.- Dashboard
POST /v1/auth/loginvalidates againstusers.password_hash(bcrypt cost 12). It mints an httpOnlyhardcount_sessioncookie bound tousers.id, not to any API key. - SDK / curl callers send
Authorization: Bearer <hc_…>.internal/session.MiddlewareOrAPIKeyaccepts either; the cookie takes precedence. - Password reset uses single-use 1h tokens delivered via SMTP (Mailpit in local dev:
docker compose up -d mailpit). - Team invites (ADR-081): tokenized email invites, and member removal with session revocation.
- No RBAC: every member holds the full permission set. The role is recorded but not enforced.
- No 2FA in v1.
- Dashboard
- Email: single delivery path.
SenderreturnsErrSMTPNotConfiguredwhenSMTP_HOSTis unset. There is no stdout fallback. The producer always wires the real adapter.- Boot logs a WARN when SMTP is not configured, and one for each unset email-link env:
HOSTED_INVOICE_BASE_URL,PAYMENT_UPDATE_URL,DASHBOARD_BASE_URL. - Without
DASHBOARD_BASE_URL, password-reset emails are not sent (an error log) and invites are refused.
- Stripe: PaymentIntent-only pattern (no Stripe Billing/Invoices to avoid its percentage fee).
- Background work: no Temporal dependency in v1.
- Background work runs as simple goroutine loops. Each loop is a leader-leased singleton role (
internal/platform/leader, ADR-114). - A leader lease is a per-tick lease row on the database clock. Its fence token is re-checked inside every claim statement. The lease is safe behind transaction-mode poolers.
- Redis is used for distributed rate limiting only. Production (
APP_ENV=production) refuses to start withoutREDIS_URL.
- Background work runs as simple goroutine loops. Each loop is a leader-leased singleton role (
- Credits: one ledger of grant, usage, expiry and adjustment entries.
- Each entry carries its currency (ADR-129).
- It is not strictly append-only:
consumed_centsandcn_retired_centson grant entries are updated in place (internal/credit/postgres.go).
Documentation discipline
Section titled “Documentation discipline”Every user-visible ship updates the docs that describe it, in the same PR:
CHANGELOG.md(Keep-a-Changelog) — what shipped, dated.docs/dev/release-checklist.mdif the change alters what a live Stripe, email or PDF check should see.docs/adr/if the change is a decision worth re-litigating later.docs/roadmap.md(“Now”, “Next”, “Later”, “Not building”, “Shipped recently”) — keep aligned with reality; if a roadmap item is silently descoped, edit the roadmap first, then act.
How to write them: Writing style for Hardcount docs (point first, one idea per sentence, define terms, clarity and truth in separate PRs).
The bar isn’t “perfect docs.” The bar is “the doc doesn’t lie.” A doc that says “logs link to stdout” when the code returns an error is worse than no doc.