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 →

Architecture Decision Records

This directory holds Hardcount’s Architecture Decision Records (ADRs) — short docs capturing one architectural decision each. Format follows Michael Nygard’s 2011 convention: Title / Date / Status / Context / Decision / Alternatives / Consequences. Every ADR also opens with a short Summary, so a reader learns the current rule and status without reading the whole record.

ADRs are written when a decision is worth re-litigating later — either because reasonable alternatives exist, because the constraints behind the choice will likely shift, or because the decision shapes multiple downstream design choices. They are not prose summaries of every change; routine refactors and bug fixes belong in commit messages + CHANGELOG.md, not here.

  • Numbering is sequential; never re-used. Superseded ADRs stay in the directory with their **Status:** Superseded by ADR-XXX line — the historical context is the whole point.
  • Status is one of: Proposed, Accepted, Superseded by ADR-XXX, Deprecated. Amendments to an accepted ADR are noted in the status line (e.g. Accepted (amended YYYY-MM-DD)); the reason goes in the amendment section, and the Summary is updated to the current rule.
  • Date is when the ADR was first written. Amendment dates go in the status line and inline section headers.
  • Multi-platform claims (“Stripe-parity”, “industry standard”) must quote verified source lines from at least 2-4 reference platforms. Single-platform spot-checks aren’t research.
  • New ADRs start from ADR-NNN: Short title that states the decision.
  • Deferred work an ADR deliberately scopes out — with a written revisit trigger in its Consequences — gets a thin pointer row in Open follow-ups below. The rationale stays in the ADR (no duplication → nothing to drift); the table only makes open deferrals discoverable in one place. Remove the row when the follow-up ships.

Each row links to the full record. Browse by Topic, or search this page for a term from the glossary.

# Date Status Topic Decision
001 2026-04-15 Accepted Payments Charge through Stripe PaymentIntents only; Hardcount owns invoices, dunning and payment status
002 2026-04-15 Accepted Platform & ops Each domain package owns its store, service and handler; peer domains never import each other
003 2026-04-15 Accepted Auth & security Every table carries tenant_id; Postgres Row-Level Security enforces tenant isolation
004 2026-04-15 Accepted Credits Append-only credit ledger: each entry carries the running balance; no entry’s amount is ever edited
005 2026-04-15 Accepted (amended); ADR-130 makes package prices decimal too (built in #1381) Billing Store and compute money as integer cents; per-unit rates later became decimals (ADR-045)
006 2026-04-15 Accepted (amended) Platform & ops Run billing and dunning on an in-process ticker; one replica runs each loop (ADR-114)
007 2026-04-29 Superseded by ADR-011 Auth & security The dashboard logs in with a pasted API key kept in browser storage
008 2026-04-29 Superseded by ADR-011 Auth & security Dashboard sessions are httpOnly cookies minted from a validated API key
009 2026-04-30 Accepted Invoices Every invoice payload carries one server-computed attention field: severity, reason, actions
010 2026-05-01 Accepted (amended) Timezones Store and send instants in UTC; show dates in the tenant’s display timezone
011 2026-05-01 Accepted (amended) Auth & security Dashboard signs in with email and password; API keys serve only SDK and curl callers
012 2026-05-01 Accepted (amended) Billing Billing periods start and end at tenant-timezone midnight; a day is the smallest unit
013 2026-05-01 Accepted Invoices Attention reports a missing payment method apart from an invoice that is awaiting payment
014 2026-05-02 Accepted (amended) Auth & security When SSO is needed, embed OIDC and SAML libraries in-process; keep passwords homegrown
015 2026-05-04 Accepted Test clocks & simulated time Advancing a test clock returns at once; a background worker runs the catch-up
016 2026-05-04 Superseded by ADR-086 Test clocks & simulated time Deleting a test clock soft-deletes it and cancels its pinned subscriptions
017 2026-05-04 Accepted Tax A background job retries failed tax calculation and finalizes the invoice when it succeeds
018 2026-05-04 Accepted (amended) Test clocks & simulated time A failed test-clock advance stores its reason and can be retried
019 2026-05-04 Accepted Tax Connecting Stripe retries invoices stuck on a missing or unauthorised tax provider
020 2026-05-04 Accepted (partly superseded) Invoices The server drops redundant provider rows from the invoice timeline and shows the charged card
021 2026-05-03 Accepted (amended) Payments Paying a hosted invoice saves the card to the customer for later off-session charges
022 2026-05-04 Accepted Payments Stripe Checkout returns the operator to the page they started from, not a fixed URL
023 2026-05-05 Accepted (amended) Payments After a decline, one banner and one email per moment; no email during on-page payment
024 2026-05-05 Accepted Invoices Keep building the timeline from several sources; add new primitives only on a named trigger
025 2026-05-05 Accepted Invoices Attention keeps Hardcount’s own explanation apart from the raw text a provider returned
026 2026-05-05 Accepted (amended) Platform & ops One function turns errors into HTTP responses; only typed, safe messages pass through
027 2026-05-05 Accepted Test clocks & simulated time Test clocks attach to the customer; subscriptions inherit the customer’s clock
028 2026-05-05 Accepted Billing Bill every due period in one call; cron and test-clock catch-up bill separate subscriptions
029 2026-05-08 Accepted Test clocks & simulated time Every time-driven job has separate wall-clock and per-test-clock flows
030 2026-05-08 Accepted Test clocks & simulated time Every action on a clock-pinned entity runs on the clock’s simulated time
031 2026-05-14 Accepted (amended); ADR-130 changes the default timing (built in #1386) Pricing & usage Each plan bills its base fee in advance or arrears; usage always bills in arrears
032 2026-05-14 Accepted (amended) Integrations A token-protected public JSON endpoint shows one customer’s costs, with sanitized fields
033 2026-05-14 Accepted (amended) Integrations LiteLLM pushes each call’s spend to a Hardcount endpoint, which records it as usage events
034 2026-05-15 Accepted; ADR-130 removes the meter lock (built in #1386) and changes the base-fee lock (not built yet) Pricing & usage A plan’s billing fields freeze once a live subscription uses it; names stay editable
035 2026-05-16 Accepted Test clocks & simulated time During test-clock catch-up, each record carries the simulated time its event happened
036 2026-05-16 Accepted (amended) Dunning A tenant keeps several dunning policies, one default, and can assign a policy per customer
037 2026-05-18 Accepted (amended) Subscriptions Shared helpers compute the first period after a trial or activation; trial changes are atomic
038 2026-05-24 Accepted Credits A credit note splits its amount across refund, credit balance and out-of-band channels
039 2026-05-30 Accepted Product scope Remove coupons entirely before launch
040 2026-05-30 Accepted (amended) Platform & ops Webhooks and emails always go through the outbox; the on/off flags are removed
041 2026-05-30 Accepted Tax If tax calculation fails, block the invoice; there is no fallback to manual tax
042 2026-05-31 Accepted (partly superseded) Tax Store tax rates as NUMERIC(7,4) and prorate with a whole-day ratio
043 2026-05-31 Accepted Tax Drop the basis-point tax rate column at once; the decimal rate is the only storage
044 2026-06-01 Accepted; ADR-130 changes decision 4 (built in #1386) Pricing & usage Meter all AI tokens on one tokens meter; token type, model and provider are dimensions
045 2026-06-01 Accepted; amended 2026-10-24 (rate limits); ADR-130 changes the rate fields (built in #1381) Pricing & usage Per-unit price rates are decimals (new rates: at most 12 places, under 10^18 cents); invoice amounts stay integer cents
046 2026-06-03 Accepted Tax Manual tax rounds the document total once and spreads the cents by largest remainder
047 2026-06-05 Accepted Tax Invoices show the statutory tax rate when taxed lines share one; else the effective rate
048 2026-06-06 Accepted Credits Credit clawbacks go through a credit note, so the proportional tax is reversed too
049 2026-06-07 Accepted Payments One idempotent settle step owns each Stripe charge’s paid or failed outcome and side effects
050 2026-06-08 Accepted (amended) Subscriptions While the period invoice is unpaid, block changes that charge more; adjust ones that credit
051 2026-06-09 Accepted Product scope Remove the customer self-serve portal; it is a B2C pattern our users do not need
052 2026-06-15 Accepted Tax The tax engine decides tax status by default; manual reverse-charge or exempt overrides it
053 2026-06-17 Accepted Payments Hardcount picks the card to charge and names it on the charge; Stripe never chooses
054 2026-06-17 Accepted Invoices Show per-unit prices at full precision; one backend function picks the billed or effective rate
055 2026-06-18 Accepted Billing Anniversary billing keeps the original day of month, clamped to each month’s last day
056 2026-06-19 Accepted Subscriptions A plan swap across billing intervals restructures the cycle in one transaction
057 2026-06-20 Accepted Credits A downgrade’s clawback credit note is created in the change transaction and issued with retry
058 2026-06-09 Accepted Timezones All month and year billing date math is anchored in the tenant timezone
059 2026-06-22 Accepted Payments In-flight payments block void, offline payment and write-off (parked excepted); clawbacks wait
060 2026-06-23 Accepted (amended) Dunning Invoices with no payment method enter dunning, like declined charges
061 2026-06-24 Accepted (amended) Credits Issuing a credit note commits internal effects in one transaction; external effects retry
062 2026-06-25 Accepted (amended) Platform & ops Move the retry sweeps onto one separate in-database obligations queue; the build is deferred
063 2026-06-28 Accepted (amended) Payments Stripe webhooks decide refund status; a retry looks the refund up before creating one
064 2026-07-02 Accepted Dunning A decline starts dunning at once; a background sweep creates any missed run
065 2026-07-02 Accepted (amended) Billing The threshold scan skips ended periods, fires once per cycle and checks every subscription
066 2026-07-02 Accepted (amended); ADR-130 changes the rule-bucket part (built in #1376), the package clamp (removed in #1381) and the window parts (not built yet) Billing Threshold fires prorate a reset’s base; tiers continue across a fire that keeps the cycle
067 2026-07-02 Accepted (amended) Subscriptions Archiving a customer is refused while a subscription still bills; it never cancels anything
068 2026-07-02 Accepted (amended) Payments One open checkout session per invoice; hosted payment and Hardcount charges exclude each other
069 2026-07-02 Accepted Subscriptions Cancelling a trial is free at trial end; SQL guards enforce it at every activation
070 2026-07-02 Accepted (amended); ADR-130 changes decisions 1, 4 and 5 (built in #1386) Pricing & usage Customer price overrides follow the rule across versions; a period keeps its opening price; a meter pricing edit that would reprice usage is refused while a subscription uses the meter
071 2026-07-03 Accepted (amended) Credits An expired credit grant is fully retired in the same transaction as its expiry entry, once no period invoice still owed can draw on it
072 2026-07-03 Accepted (amended) Platform & ops Outbox workers claim rows with a time-limited lease; budgets and lease lengths are derived
073 2026-07-03 Accepted (amended) Platform & ops One bootstrap path creates a tenant in one transaction; migrations run one at a time
074 2026-07-04 Superseded by ADR-077 Timezones Each subscription snapshots its billing timezone at creation
075 2026-07-04 Accepted Timezones The process runs in UTC, so API timestamps are the same on any host
076 2026-07-04 Accepted Timezones A Go test and typed dashboard helpers make every shown date name its timezone
077 2026-07-04 Accepted Timezones One org-level billing timezone; subscriptions have none, and each invoice keeps its issue zone
078 2026-07-05 Accepted (amended) Credits A prepaid commit is a credit grant funded at invoice finalize and retired on void
079 2026-07-05 Accepted (amended) Pricing & usage Stamp each usage event’s provider cost at ingest; report usage margin per customer
080 2026-07-06 Accepted Credits Refund unused paid commit credits at the price paid per credit, in one transaction
081 2026-07-06 Accepted Auth & security Invite teammates by single-use email link; removal ends sessions; roles are not enforced
082 2026-07-06 Accepted (amended) Platform & ops Up to 10 extra addresses are CC’d on six email types, never on credential emails
083 2026-07-07 Superseded by ADR-085 Pricing & usage A recipe reuses an existing plan or meter only if its money settings match
085 2026-07-08 Accepted (amended); ADR-130 removes rule adoption (built in #1386) and step 4 (built in #1389) Pricing & usage Applying a recipe only adds: a first apply creates a new plan; repeats create nothing
086 2026-07-09 Accepted (amended) Test clocks & simulated time Money sweeps skip simulated invoices; deleting a clock deletes its data except the audit log
087 2026-07-11 Superseded by ADR-116 Payments One method collects payment after every engine finalize; each site keeps its own checks
088 2026-07-11 Accepted (amended) Credits The credit balance applies to every invoice when it is collected, and again on each later attempt; the card is charged the remainder
089 2026-07-13 Accepted Audit A failed audit write never changes the API response; it is logged and counted
090 2026-07-13 Accepted Audit Audit rows should commit in the business transaction; most writers still write after commit
091 2026-07-14 Accepted Timezones Changing the org timezone never bills a full fee for the short period it creates
092 2026-07-14 Proposed Timezones Split the billing timezone from the display timezone; deferred until a partner needs it
093 2026-07-16 Accepted Auth & security State-changing cookie requests must come from the same site, checked by origin headers
094 2026-07-17 Accepted (amended) Auth & security No login lockout. Repeated wrong passwords wait 1 to 15 minutes, per address and email and per email. A browser that signed in before is not slowed by other people’s guesses (shipped 2026-10-18)
095 2026-07-17 Accepted (amended) Auth & security TOTP MFA is designed, not built, and waits for a user or security-review ask; the offline breached-password check and the always-Secure production cookie shipped 2026-10-18
096 2026-07-18 Accepted (amended); ADR-130 removes the attach and meter-in-use guards (built in #1386) Pricing & usage Plans attach only priced meters; a billing meter’s last price cannot be removed
097 2026-07-18 Accepted (amended) Subscriptions A scheduled cancel that falls mid-period fires as an immediate cancel at that time
098 2026-07-19 Accepted Integrations Ingest Postmark delivery, bounce and spam-complaint webhooks; ignore opens and clicks
099 2026-07-21 Accepted (amended) Test clocks & simulated time Say “Simulated” once per scope: one page banner, or one chip per list row
100 2026-07-26 Accepted (amended); ADR-130 removes G1, G2, G2b and G4 (built in #1386) Pricing & usage Reject mismatched currencies at every write; the engine never converts
101 2026-07-27 Accepted Subscriptions Store each item’s billable date ranges on change; billing intersects them with the period
102 2026-07-28 Accepted (partly superseded, amended) Payments Record each charge attempt as a row, updated by PaymentIntent id as Stripe reports
103 2026-07-28 Accepted Payments The invoice timeline shows payments only from charge-attempt rows, not from the webhook table
104 2026-07-29 Accepted (amended) Test clocks & simulated time One invoice timeline ordered by the entity’s own clock; the real time shows beneath
105 2026-07-30 Accepted (amended) Payments Seed the Stripe idempotency key from a per-invoice attempt counter, not updated_at
106 2026-07-30 Superseded by 127 Payments Record each charge before calling Stripe, so an unnamed charge can be replayed safely
107 2026-07-31 Accepted (amended) Payments An unclear charge parks the invoice; Stripe’s list of the customer’s payments resolves it within a day
108 2026-08-02 Superseded by 127 Payments Resolve unknown charges by searching Stripe, and act only on a payment actually found
109 2026-08-05 Accepted (amended) Invoices Issued documents print bill-to and supplier details from a copy taken at issue
110 2026-08-05 Accepted (partly superseded) Invoices Customers cannot pay a written-off invoice online; its public pages point to support
111 2026-08-05 Accepted Tax Writing off an invoice does not reverse its tax; voids and credit notes still do
112 2026-08-05 Accepted (amended) Dunning When dunning ends, decide the subscription’s outcome and the invoice’s outcome separately
113 2026-08-06 Accepted Invoices Nothing charges a written-off invoice; only a recorded payment or a found charge settles it
114 2026-08-30 Accepted (partly superseded) Platform & ops Leader election is one database row per job, with a fencing token on every claim
115 2026-08-30 Accepted Billing Every period write locks the subscription and rechecks its period snapshot first
116 2026-10-08 Accepted (amended) Payments Finalize queues the invoice in the same write; one collector charges every queued invoice
117 2026-10-11 Proposed Payments One PaymentIntent per invoice considered and deferred; Hardcount keeps one per charge attempt
118 2026-10-12 Accepted (amended) Credits Credit for unused time on cancel or plan swap is written in the same transaction
119 2026-10-12 Accepted Billing Billing failures are saved on the subscription; retries back off and park after eight
120 2026-10-14 Accepted (amended); ADR-130 changed the after_close rule and counter (built in PR-2 and PR-6) Billing Invoices are built after a grace period (default 1h); later usage is not billed automatically
121 2026-10-16 Accepted Pricing & usage The customer page’s Activity and Margin cards open on the current billing period
122 2026-10-17 Accepted (amended 2026-10-27) Pricing & usage LiteLLM usage for an unknown customer or meter is held until an operator releases it
123 2026-10-17 Accepted; amended 2026-10-26: recipes create no policy (ADR-130) Dunning New accounts start with a default dunning policy that retries three times and changes nothing
124 2026-10-17 Superseded by ADR-125 Platform & ops Run migrations with plain golang-migrate and a 5-second lock limit per file; never built
125 2026-10-18 Accepted (amended 2026-10-22, 2026-10-28) Platform & ops From v0.9.0, run migrations with goose from one baseline; v0.9.0 installs fresh only
126 2026-10-18 Accepted Dunning After a hard decline, dunning charges that card no more for that invoice until another card is the default
127 2026-10-19 Accepted (amended) Payments A charge whose result was lost or unclear blocks every new charge and irreversible action until a sweep has asked Stripe
128 2026-10-19 Accepted Test clocks & simulated time Every activity line has one wording, names who acted and follows one clock rule; the backend builds the words
129 2026-10-23 Accepted Credits Each credit ledger row stores its currency; credit pays only invoices in it, never converted; a manual invoice uses the customer’s currency
130 2026-10-23 Accepted (lock order, one rater, one aggregation per meter, price shapes, plan prices, catalog-only recipes and ingest’s unbilled statuses shipped; PR-7 cut, PR-8 not built yet) Pricing & usage Usage prices are rows on the plan; the most specific price wins; an edit on a used plan applies from each subscriber’s next period
131 2026-10-24 Accepted Integrations A webhook event’s data is the resource it is about, as the API returns it, plus the few facts the resource lacks; one resource per family

ℹ️ ADR-084 was never used. No file has ever carried that number (verified across every ref). It is a skipped number, not a lost decision.

⚠️ This index sat frozen at 082 for a month while ADRs 083–112 shipped — 28 decisions, including the whole written-off-invoice arc (110/111/112), were undiscoverable here. Backfilled 2026-08-05; dates for the 20 whose **Status:** line carried no date come from each file’s first commit. Nothing enforces this table, so it rots silently: add the row in the same PR as the ADR.

ℹ️ ADR-058 was renumbered from a duplicate ADR-050 (2026-06-21). Two concurrent sessions had each taken 050 — the same hazard the migration-numbering rule guards against (pick the next number from origin/main, not a local branch). The earlier-dated 050 (unpaid-source-proration, 2026-06-08) kept the number; the later (date-math, 2026-06-09) became 058 and all its references were updated. The out-of-date-order number is expected for a renumber.

Work an ADR deliberately scoped out, as thin pointers — the authoritative rationale and revisit trigger live in each ADR’s Consequences/Deferred section; this table only makes the open items discoverable in one place. Remove a row when its follow-up ships. (The first four share one shape: a post-commit side-effect / external call not yet in-tx or idempotent. None is a regression; each is guarded today and gated on a named trigger — see feedback_pre_launch_scoping. The rest are activity lines that ADR-128 left out.)

Follow-up ADR Code site Revisit trigger
Stale-deferred-draft alarm — Part B (ADR-059) defers an automated clawback against an in-flight source until the charge settles. If the charge never settles (a wedged requires_action PI nobody authenticates/cancels), the deferred draft waits unissued. It is not lost (durably captured, auto-issues on settle) and a wedged payment is independently visible (stuck processing invoice, tenant unpaid), so this is an observability gap, not a correctness one: surface a draft deferred > N days so an operator can cancel/await the PI (which auto-resolves the clawback). 059 §Deferred internal/creditnote/service.go · RetryPendingClawbackIssue operability hardening / first ACH-SEPA design partner
amount_paid edge — Part C: record from captured — MarkPaid records amount_paid = amount_due at settle. Under Hardcount’s PaymentIntent-only full-capture model this equals the captured amount, so it is not reachable today (Hardcount exposes no partial/manual-capture flow). If partial capture is ever added, record from the PI’s amount_received (the processor’s captured amount) instead. 059 §Consequences internal/invoice/postgres.go · MarkPaid partial-capture support
Per-customer balance_low threshold — credit.balance_low fires only on the single tenant-level threshold; consumers cannot implement per-customer low alerts from it (falsified 2026-07-06 — the design doc’s consumer-side mitigation was impossible). Workaround: smallest-customer tenant threshold + always-per-customer balance_depleted + polling GET /v1/credits/grants/{id}. Since ADR-129 the one threshold applies in each currency’s own units. 078 §D8 (amended), 129 internal/credit/postgres.go · emitBalanceCrossings first DP with heterogeneous commit sizes asking for per-customer low alerts, or a tenant asking for a threshold per currency
DispatchTx seam — atomic lifecycle-event emission (remaining perimeter) — the subscription lifecycle subset SHIPPED 2026-07-05: subscription.created / .activated / .canceled / .trial_ended are enqueued IN their transition txs at the store level (subscription.PostgresStore.SetOutboxEnqueuer, one emit site per transition covering the operator API + engine schedule paths + trial sweeps; the ~10 scattered post-commit dispatch sites were deleted). The settlement money events (invoice.paid, payment.succeeded) were already in-tx. dunning.started joined them 2026-10-08 (ADR-116: dunning.Store.StartRun writes run + timeline event + webhook in one tx). Remaining: the ~12 other notification events (item/pending-change/collection/dunning/invoice.finalized/voided etc.) still dispatch post-commit — a dropped enqueue leaves no row anywhere and the loss is silent (there is no consumer-reconciliation mechanism). Per-service dispatchEvent ERROR-logs failures. dual-write audit (no ADR) + 2026-07-05 reassessment internal/subscription/postgres.go · enqueueLifecycle (shipped) / internal/domain/webhook_outbound.go · EventDispatcher (rest) first DP integration that consumes a non-subscription lifecycle event
A customer-level activity timeline — customer events (profile, cards, setup links, credit grants) show only in the audit log. 128 §Consequences — an operator asks “what happened to this customer” and the audit log filter is not enough
An activity line when the final dunning action fails — the run retries it every 24 hours while the invoice timeline stays quiet; the Dunning badge says “Final action failed”. 128 §Consequences internal/dunning/service.go an operator misses a stuck final action, or asks why an invoice shows no change
An activity line when a subscription’s billing run fails — the page banner shows the current failure, but past failures have no line. 128 §Consequences internal/subscription/handler.go · describeSubscriptionAction an operator needs the history of the failures, not just the current state
  • Clawback post-flip partial-issue window: resolved by ADR-061 (#313). See 057 and 061.
  • Cross-interval swap refund lost or double-credited on crash-retry: resolved 2026-07-05 (#381). See 056.
  • SettleFailed event, email and dunning in-tx, plus dunning recovery: resolved. See 064.
  • Commit credit-note retire leg: resolved 2026-07-06. See 080.
cp TEMPLATE.md NNN-short-slug.md

Pick NNN as the next sequential number — check ls docs/adr/, NOT your local branch (per feedback_migration_numbering: numbering is chosen from origin/main to avoid duplicates that only fail at integration-test time).