Design — Prepaid commits + drawdown
- Decided: a prepaid commit (credit a customer pays for up front, then uses up) is a line on a manual invoice. Finalizing that invoice grants the credit in the same transaction, and voiding it retires what is left. Customer credit never pays a commit invoice, and promotional credit is spent first.
- Shipped: v0.1.0, in #371 (migration
0136_prepaid_commits), with the dashboard in #372. Design written 2026-07-05.- Current docs: Sell prepaid credit in the AI pricing reference. The current rules are ADR-078. Where this page and the code disagree, the code wins.
Status: LOCKED for build (2026-07-05) after (1) Orb 3-0 adversarial verification, (2) a 4-platform peer sweep (Metronome/Lago/Stripe/AI-infra — 43/45 claims quote-verified), and (3) a 6-lens adversarial design panel (wf_7f79b774: accounting, atomicity, concurrency, idempotency, scope, site-set — all verdicts folded in below; 4 census errors corrected). Decision record: ADR-078.
2026-08-23 ship-drift notes (recorded design vs current code): the dunning
final_actionnamed in D3 and the census has since split into two axes —final_subscription_action/final_invoice_action(ADR-112;internal/dunning/handler.gorejects the old field). The D10 composer commit fields shipped on the customer page’s invoice composer (CustomerDetail.tsx), not InvoiceDetail. D9’s “next LockKey 76540008” has since been claimed byLockKeyTopologyCheck(advisory_lock.go); the substantive claim — commits need no lock key — still holds. D5’s check-then-act inventory predates the finalize CAS, which has shipped (invoice/postgres.gocites ADR-078 D5).
Why (wedge fit)
Section titled “Why (wedge fit)”A prepaid commit is an up-front purchase of usage credits: the customer
commits to a spend amount, is invoiced for it, and then draws the
resulting credit balance down (drawdown) as usage consumes it. Commits +
drawdown is a core pillar of the product’s wedge, its market-entry
differentiator ([[project_positioning_wedge]] item 6). The first DP
(design partner — an early customer who co-develops the product)
profile — AI infra, Series A–B — sells commit + usage. Hardcount
already has the metering and an Orb-style credit-block ledger (Orb: a
commercial usage-billing platform peer): consumed_cents per block,
FIFO drain, structural idempotency — dedup enforced by the schema
itself, not by application checks — and ADR-071 atomic expiry; the
commit primitive is the missing headline.
Peer-verified: gross purchase invoicing (the commit purchase is billed
on a real invoice) is unanimous; exhaust→overage (usage past an
exhausted balance bills as overage rather than being blocked), expiry,
and $0 fully-covered invoices are already correct in Hardcount.
Phase 1 (build target) — locked decisions
Section titled “Phase 1 (build target) — locked decisions”D1. Discriminator: grant_kind on the ledger
Section titled “D1. Discriminator: grant_kind on the ledger”entry_type='grant' + new nullable grant_kind
('commit' | 'promotional' | NULL) + new source_invoice_id column
(the existing invoice_id is non-unique/multi-row — NOT reusable) with
a partial-unique index (unique only over the rows matching its WHERE
clause):
CREATE UNIQUE INDEX idx_credit_ledger_commit_fund_dedup ON customer_credit_ledger (tenant_id, source_invoice_id) WHERE source_invoice_id IS NOT NULL AND grant_kind = 'commit';Legacy grants stay NULL and drain in the paid class (they are
money-derived liabilities, drained after promotional credits — the
drawdown order below). promotional is only ever explicit operator
input. Zero behavior change until a tenant creates a promotional grant
or commit.
D2. Funding: grant-on-issue ONLY (payment gate → phase 2)
Section titled “D2. Funding: grant-on-issue ONLY (payment gate → phase 2)”Grant-on-issue: the credits become drawable the moment the funding
invoice is finalized — payment is not awaited. The commit grant lands
in the Finalize coordinator tx — the
UpdateStatusWithReversal shape (status flip + caller-supplied ledger fn,
both-or-neither), via a narrow service-level granter interface mirroring
s.creditReverser in Void (invoice/service.go:807). Grant carries
grant_kind='commit', source_invoice_id, optional expires_at.
- Industry-verified default (Metronome/Orb): negotiated Net-N (pay within N days) B2B commits are drawable at issue; payment follows terms.
- A granter error fails Finalize by design: operator-synchronous (the finalizing operator sees the failure immediately), loud, and retryable — the finalize CAS (compare-and-swap: the status flip fires only if the row is still in the expected state) makes the retry clean. Never weaken to firing the grant after the transaction commits (post-commit).
- The fund-once index is a pure structural backstop: the finalize CAS
(D5) already guarantees the granter runs at most once, so an index
violation = broken invariant = loud tx abort. The 0093/0106
catch-ErrAlreadyExists pattern is safe only in its own transaction
(own-tx-only) and FORBIDDEN inside a shared coordinator tx
(poisoned-tx: after any error Postgres aborts the surrounding
transaction, so every later statement in it fails; see
GrantForCreditNoteTx’s documented contract). - Deferred to phase 2 (trigger: first DP asks pending-until-paid, or
the self-serve/auto-top-up build, which hard-defaults to gated): the
per-commit payment gate, the
markPaidReportingTransitionfunder hook, and any pending-block state. Verified safe to defer: gating is opt-in default-off at Orb and Metronome too, and the paid-transition chokepoint (all 11 MarkPaid callers converge atmarkPaidReportingTransition) is additive later. - The funder hooks ONLY the finalize transition. It is never wired into
MarkPaymentFailedReportingTransition(verified: a stale payment.failed cannot un-pay or touch grants).
D3. Unwind: retire on VOID only
Section titled “D3. Unwind: retire on VOID only”Void of an invoice carrying a commit line retires (permanently zeroes
out) the funded grant’s remaining balance in the same
UpdateStatusWithReversal tx.
- Mechanism:
RetireGrantRemainingTx— ADR-071ExpireGrantAtomic’s locked shape executed on the caller’s tx: customer advisory lock (a Postgres application-defined lock, keyed here on the customer) → grant row re-read underFOR UPDATE→consumed_cents = amount_centsflip gated onconsumed_cents < amount_cents(the exactly-once CAS; 0 rows = clean no-op) → append the negative entry sized from the IN-TX value. Diverges from ExpireGrantAtomic deliberately: runs on the caller’s tx; works with NULLexpires_at; entry stamped at void-timeclock.Now(never the grant’s expiry — no future-dated entries);entry_type='adjustment'withmetadata.reason='commit_void_retire'(it is not a term lapse; documented that void-retires appear under adjustments, not TotalExpired). - Uncollectible, dunning pause, and cancel_subscription do NOT retire — the block stays live as a collections decision, consistent across all three non-void terminals of dunning, the automated payment-failure retry-and-escalation process (dunning final actions are pause/cancel_subscription/mark_uncollectible; void is operator manual-resolve only). This also makes uncollectible→paid recovery correct for free: the grant was never retired, so there is nothing to restore, and voided→paid is rejected so a retired grant can never be paid-for. Operator forcing function: void the invoice to kill the credits.
- Consumed stays consumed, always.
D4. Commit invoices are cash instruments
Section titled “D4. Commit invoices are cash instruments”- Credit balance can never pay a commit funding invoice.
ApplyToInvoiceAtomicno-ops (returns 0) for invoices carrying a commit line — enforced INSIDE Apply via the in-tx invoice re-read (D6), covering all callers (auto-charge sweepengine.go:5273, dunning retryadapters.go:340, engine cycle paths). Otherwise a card-less customer’s just-granted commit credits drain into their own funding invoice (“credits buy credits”: $0 cash, revenue booked). Stored-PM (saved payment method) auto-charge on a commit invoice stays allowed (real cash). - Credit notes are BLOCKED on invoices carrying a commit line — typed error at credit-note (CN) create, both pre-pay (a concession would decouple cash from grant size) and post-pay (refund would return cash while the block stays drawable — Void is blocked on paid invoices, so CN is the only paid-relief channel and it double-gives). Unwind paths: unpaid → void (retires); paid → phase 2 CN-retire leg (trigger: first DP commit refund ask; design sketch: retire min(remaining, CN gross) in the CN coordinator tx, refund capped by price×remaining/granted).
D5. CAS prerequisites on hook-carrying flips (prod fix, in scope)
Section titled “D5. CAS prerequisites on hook-carrying flips (prod fix, in scope)”All three status writers the hooks ride are check-then-act today (check-then-act: read the guard state, then write without re-checking, letting two racers both pass — service-layer guards on stale pre-reads; UPDATEs have no status predicate). Before money hangs off them:
FinalizeWithDates:... SET status='finalized' ... WHERE id=$X AND status='draft'; 0 rows → re-read →errs.InvalidState. Granter runs only when the flip transitioned.updateStatusInTx(void/uncollectible):SELECT ... FOR UPDATE, in-tx re-check of allowed source statuses (void: draft/finalized/ uncollectible — never paid; uncollectible: finalized), then flip.- Kills: void→finalize resurrection minting a grant on a voided invoice; pay-vs-void retiring credits the customer just paid for.
- Concurrent mutation-verified tests required (playbook §5.3/§5.4).
D6. One global lock order: invoice-row → advisory → ledger-rows
Section titled “D6. One global lock order: invoice-row → advisory → ledger-rows”Corrected census (the panel’s site-by-site inventory of writers and
lock orders): ApplyToInvoiceAtomic runs its OWN tx (not the
billing tx), takes NO advisory lock, and orders ledger-rows→invoice-row
— the reverse of the lock order the new hook paths take
(invoice→advisory). With
expiry’s advisory→ledger-rows this is a reachable 3-way deadlock cycle
on the feature’s hottest flow. Fix, in scope:
ApplyToInvoiceAtomic: lock the invoice row FOR UPDATE first, re-readstatus/amount_due_cents/commit-line presence in-tx — no-op unless status ∈ (draft, finalized) AND due > 0 AND not a commit-funding invoice (D4) — then advisory lock, then ledger rows, drain against the re-read amount (kills the stale-amount_due race: settle-vs-reapply silently burning credits into a paid invoice). NOTE: draft stays allowed — billOnePeriod applies credits to tax-pending drafts.AdjustAtomic: advisory lock before its row locks.- Grant path (advisory only) and expiry (advisory → ledger rows) are consistent suffixes; expiry takes no invoice locks.
- This unification is ALSO what makes alert crossings well-ordered (D8): today grant-vs-drain writers share no lock at all.
D7. Drawdown order (NULL-safe)
Section titled “D7. Drawdown order (NULL-safe)”ORDER BY (grant_kind IS NOT DISTINCT FROM 'promotional') DESC, expires_at NULLS LAST, created_at, id(A bare (grant_kind = 'promotional') DESC sorts NULL first — verified
against live Postgres — draining legacy money-derived credits before
free promo: the exact inversion this feature exists to prevent.)
Applies to BOTH drainPositiveBlocks callers — drawdown
(ApplyToInvoiceAtomic) and clawback attribution
(AdjustAtomic; clawback: revocation of already-granted credits) —
promotional-first is intended for both. Mixed-kind regression test
required. No operator priority field (Metronome/Lago/Stripe numeric
priority = named phase-2 escape hatch).
D8. Balance alerts (green field)
Section titled “D8. Balance alerts (green field)”- Events:
credit.balance_low(crossed below per-tenant threshold),credit.balance_depleted(>0 → 0),credit.balance_recovered(0 → >0). Recovered is kept NOT for parity (single-peer, Orb) but as the state-machine complement of depleted — without it a consumer’s depleted state can never clear. - Payloads carry
customer_id,balance_cents,threshold_cents. AMENDED 2026-07-06 (deferral reassessment falsified the original mitigation):balance_lowfires ONLY on crossing the single tenant-level threshold, so “implement per-customer logic consumer-side” is impossible for that event — a consumer never sees a crossing for a customer whose commit dwarfs the tenant threshold. The workaround that actually exists: set the tenant threshold to the SMALLEST customer’s alert level, rely onbalance_depleted(always fires per customer), and pollGET /v1/credits/grants/{customer_id}for per-customer burndown. Per-customer threshold override (COALESCE(customer, tenant)) remains the named phase-2 escape hatch — trigger: first DP with heterogeneous commit sizes asking for per-customer low alerts (tracked in docs/adr/README.md follow-ups). - Crossings computed on
SUM(amount_cents)before/after inside each ledger-writing tx via ONE shared helper called from the 3 insert sites (appendEntryInTx,AdjustAtomic,ApplyToInvoiceAtomic— expiry converges into appendEntryInTx). Well-ordered per customer because of D6. Known, documented lag: an expired-but-unswept block keeps SUM > 0 until the sweep (the background expiry job) retires it — the sweep’s expiry entry produces the crossing (minutes-scale lag, matches the existing expiry discipline). Do NOT refactor apply/adjust to funnel through appendEntryInTx — their drift-capping logic is deliberately different. - Wiring: credit store gets an outbox enqueuer injection mirroring
invoice’s
SetOutboxEnqueuer(invoice/postgres.go:40) — the credit store has ZERO outbox wiring today. Enqueued in the same tx = ADR-040 transactional outbox (the event row commits atomically with the state change; a dispatcher delivers it afterwards). No sweep, no LockKey (a registered advisory-lock key that leader-gates a background worker) needed. - Threshold:
tenant_settings.credit_balance_low_threshold_cents(nullable; NULL = no low alerts; depleted/recovered always on). - Cleanup ride-along: delete the phantom
HARDCOUNT_BILLING_ALERTS_INTERVALfromdeploy/compose/docker-compose.yml:118(nothing reads it).
D9. Commit line schema + validation
Section titled “D9. Commit line schema + validation”Migration 0136 (number verified unclaimed — free — across main + all branches; the next LockKey 76540008 is NOT needed — no sweep):
invoice_line_items:commit_granted_cents BIGINT NULL,commit_expires_at TIMESTAMPTZ NULL, CHECK (commit_granted_cents IS NULL OR (line_type='add_on' AND commit_granted_cents > 0)). Dedicated columns, NOT metadata JSONB (unvalidatable money config) and NOT a side-table (orphan lifecycle) — matches the table’s existing nullable per-type column pattern; no tax-drift risk (written once at line create, read by exactly one consumer, never copied by engine writers).customer_credit_ledger:grant_kind TEXT NULLCHECK (grant_kind IN ('commit','promotional')),source_invoice_id TEXT NULL, the D1 partial-unique index.tenant_settings: threshold column (D8).- Validation lives in
buildLineItem— the shared funnel for BOTH line-creation entry points (service.Createcreate-with-lines — the path of the composer, the dashboard’s one-off invoice builder — ANDservice.AddLineItem; “enforced at AddLineItem” alone would miss the composer). Rules: commit fields ⇒line_type='add_on',billing_reason='manual',granted_cents > 0,expires_atin the future if set, at most one commit line per invoice (keeps the per-invoice fund-once index sufficient; multiple commits = multiple invoices in phase 1). - Expiry: optional explicit
commit_expires_at, default never — “term-aligned default” is incoherent for manual invoices (no term object to align to; a silent invented term is the banned silent-fallback class). Term-aligned becomes the default in phase 2 when recipe-carried commits (commits bundled in a pricing recipe — a reusable plan-provisioning template) attach to subscriptions. “Never” — no expiry at all — stays a first-class option (Lago/Stripe/Together default). - The existing $1M per-grant cap stays for commit grants (operator splits larger commits; raise-on-first-DP-ask, documented).
D10. Phase-1 ship surface (definition of done)
Section titled “D10. Phase-1 ship surface (definition of done)”Backend: migration 0136; D2 granter + D3 retire + D5 CAS + D6 lock
order + D7 order-by + D8 alerts; API AddLineItemInput +
create-with-lines commit fields; CN block; Apply exclusion.
Frontend (web-v2): composer commit fields on InvoiceDetail (“pay $X,
grant $Y credits, expires …”); Credits page grant_kind badge +
funding-invoice link; Settings threshold input.
Docs: ADR-078; CHANGELOG; MANUAL_TEST FLOW (sell commit → finalize →
draw → deplete alert → void-retire); webhook event docs.
Tests: mixed-kind drain order; finalize-CAS concurrent double-finalize;
funder-error-fails-finalize (invoice stays draft, no ledger row);
void-retire exactly-once incl. legal uncollectible→void sequence;
concurrent MarkPaid×2 (no grant — gate is phase 2, so assert NO paid
hook); credits-cannot-pay-commit-invoice; CN-blocked; crossing events
under concurrent grant+drain (post-D6).
Known accepted limitations (documented, phase-2 triggers named)
Section titled “Known accepted limitations (documented, phase-2 triggers named)”- ReverseForInvoice laundering (pre-existing class): voiding a drawdown invoice re-grants drawn credits as a fresh NULL-kind, never-expiring block (no per-block attribution exists). Phase 1 inherits: reversal grants stay NULL-kind (paid class — correct-ish); a commit’s drawn slice escapes its expiry on drawdown-invoice void, and the co-occurrence with void-retire — a retired commit’s already-drawn slice can resurface through such a re-grant — is accepted at zero customers. Phase-2 trigger: first DP with expiring commits → design per-block drain attribution on usage entries.
- Uncollectible/pause/cancel leave commit blocks live (D3) —
collections stance. The fast-follow named here (surfacing unpaid-funded
grants in the attention dashboard) shipped 2026-08-04: a
commit_exposurereason +void_invoiceaction, folded onto whatever banner the invoice already raises rather than competing with it. The block still stays live — that part is unchanged and deliberate; it is no longer invisible. See the ADR-078 amendment of the same date. - Reporting stays face-value-aggregate (dashboard credit balance = raw SUM across kinds; a discounted commit’s cash≠face gap — cash collected vs face value granted — is not surfaced). Phase-2: kind-split in GetBalance/overview when a DP asks.
- Alert lag on expired-unswept blocks (D8).
Phase 2 (deferred, triggers named)
Section titled “Phase 2 (deferred, triggers named)”Payment gate + markPaid funder hook (first pending-until-paid ask / self-serve build) · auto-recharge (hard-default gated) · CN-retire leg for paid commit refunds · recipe-carried commits + term-aligned expiry default · numeric drain-priority override · per-customer threshold override · pending-block visibility · true-up (postpaid construct) · kind-split reporting · per-block drain attribution.
Corrected census (per panel; verified file:line)
Section titled “Corrected census (per panel; verified file:line)”ApplyToInvoiceAtomic: OWN tx (credit/postgres.go:469), called after the invoice-create tx committed (engine.go:3114); NO advisory lock (scope caveat credit/postgres.go:61-65); order ledger-rows FOR UPDATE (:480-485) → invoice-row UPDATE (:559-565).- Ledger insert sites: 3 (appendEntryInTx :103, AdjustAtomic :359, ApplyToInvoiceAtomic :546); expiry’s entry converges into appendEntryInTx (:263).
- Manual-invoice finalize entry: operator handler ONLY
(
invoice/handler.go:442→service.Finalize:550) — tax-retry auto-finalize excludes operator-composed invoices (shouldAutoFinalizeAfterRetry, service.go:1206-1217). - Line-creation entries: create-with-lines (service.go:329→436-447) AND
AddLineItem (:1063), shared funnel
buildLineItem(:1083). - Dunning final actions: pause / cancel_subscription / mark_uncollectible (dunning/service.go:31-50); void = operator manual-resolve only (dunning/handler.go:34-42).
- Paid-transition chokepoint: 11 callers converge at
markPaidReportingTransition(FOR UPDATE + already-paid early return verified safe for redelivery). - Credit store outbox wiring: none today — injection required (D8).
- Next migration 0136; ADR-078 free across branches.
Research provenance
Section titled “Research provenance”- Orb 3-0 (2026-07-03): gross purchase, deterministic drawdown, overage-not-blocked, in-arrears-only, configurable expiry, auto-top-up, require_successful_payment.
- Peer sweep (2026-07-05, wf_7b081b9a): Metronome 10/12, Lago 10/10, Stripe 7/7, AI-infra 16/16 — quote-checked by adversarial verifiers.
- Design panel (2026-07-05, wf_7f79b774): 6 lenses, 6× amend, all folded; census errors corrected above.