Skip to content
Hardcount
Run it locally
Pre-1.0 · one maintainer · Stripe and cards only · no SDKs (client libraries) yet · no production users yet. Docs describe v0.9.1 (29 Oct 2026) · What’s new →

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.

Each rule is explained in the section named after it.

  • Architecture: a domain never calls another domain’s Service/Store directly. internal/arch/boundaries_test.go enforces 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.
  • 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/Store directly.
  • 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 billing coordinator. The billing engine orchestrates peers through narrow consumer-defined interfaces.
  • A new cross-domain import edge fails internal/arch/boundaries_test.go until 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 in docs/architecture.md (Design rules).
    • A new TxBypass use fails internal/arch/txbypass_allowlist_test.go until it is listed.
  • 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).
  • HMAC-SHA256 webhook signing (both inbound Stripe and outbound).

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.

Terminal window
docker compose up -d postgres
DATABASE_URL="postgres://hardcount:hardcount@localhost:5432/hardcount?sslmode=disable" go run ./cmd/hardcount-bootstrap
DATABASE_URL="postgres://hardcount:hardcount@localhost:5432/hardcount?sslmode=disable" RUN_MIGRATIONS_ON_BOOT=true go run ./cmd/hardcount

How we test, which test to write for which change, and how to run each layer: How we test Hardcount.

Terminal window
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.

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 on main. 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, list docs/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 two 050s).
  • 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=false runs from two sessions can interfere. Run unit tests freely, and treat CI as the integration gate.
    • One make dev / vite (ports 8080/5173).
  • 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/login validates against users.password_hash (bcrypt cost 12). It mints an httpOnly hardcount_session cookie bound to users.id, not to any API key.
    • SDK / curl callers send Authorization: Bearer <hc_…>. internal/session.MiddlewareOrAPIKey accepts 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.
  • Email: single delivery path.
    • Sender returns ErrSMTPNotConfigured when SMTP_HOST is 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 without REDIS_URL.
  • Credits: one ledger of grant, usage, expiry and adjustment entries.
    • Each entry carries its currency (ADR-129).
    • It is not strictly append-only: consumed_cents and cn_retired_cents on grant entries are updated in place (internal/credit/postgres.go).

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.md if 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.