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 →

ADR-063: Refund status is reconciled from Stripe webhooks (async truth)

Date: 2026-06-28 Status: Accepted (amended 2026-08-04)

Stripe webhooks decide a refund’s final status, because Stripe refunds settle later and a pending refund can still fail. Hardcount records the status Stripe returns at creation, and the refund webhooks then move it to succeeded or failed; Stripe’s canceled counts as failed. A late pending never overwrites a final status, and the dashboard flags a refund that failed or has been pending over 72 hours. Amended 2026-08-04: because Stripe idempotency keys expire after about 24 hours, a retry first looks the refund up at Stripe and creates a new one only when none is live. Status: active, amended 2026-08-04; refunds are retried only by the operator.

Stripe refunds are asynchronous. Refund.create is idempotent and often returns status=pending; the final outcome (succeeded / failed / canceled) lands later via a webhook. Even succeeded means “submitted to the card network”, not “on the cardholder statement” (5–10 business days, no confirming event). A pending refund can flip to failed (bank reject / insufficient platform balance → money returns to the platform balance, the customer gets nothing).

Hardcount recorded refund status optimistically from the create-call: StripeRefunder.CreateRefund dropped ref.Status and creditnote.Issue() / RetryRefund hard-coded refund_status = succeeded on any non-error create. The webhook switch handled no refund events. So a refund recorded succeeded that Stripe later failed was a silent, money-wrong state — the customer is owed money, and nothing surfaced it (it wasn’t even failed).

#319 had just shipped a “refunds need attention” dashboard alert + Credit Notes filter keyed on refund_status IN ('failed','pending') — written when pending was rare (only “no refunder/no PI at issue”).

  1. Record the create-time status faithfully. CreateRefund returns Stripe’s actual status (mapped to a Hardcount refund_status); Issue() and RetryRefund record pending when Stripe says pending, not a blanket succeeded. (The common healthy card refund still returns succeeded synchronously.)

  2. The webhook is the source of truth for the async outcome. Handle charge.refund.updated, refund.updated, and refund.failed (all carry a Refund object; one status-driven handler) — admitted via endpointAuthoritativeEvent (refunds carry no hardcount_* metadata; the per-tenant endpoint + HMAC is authoritative). Match webhook→credit-note by stripe_refund_id; apply monotonically (terminal wins; a stale out-of-order pending never clobbers a terminal). Reuse the existing event-id dedup. A refund with no matching credit note (Stripe-dashboard / direct-API refund) is ack’d permanently, never auto-creates a credit note; a very recent unmatched refund gets a bounded (15-min) redelivery to cover the rare create→webhook race.

  3. Status mapping (lossy, no migration). Stripe’s 5 states collapse into Hardcount’s 4-value refund_status CHECK: succeeded→succeeded, failed→failed, canceled→failed (money returned to platform — the operator-actionable bucket), pending→pending, requires_action→pending. The canceled→failed collapse is the re-litigable call here.

  4. Co-refine the #319 alert (same change) so faithful pending doesn’t flood it: “needs attention” = failed OR (pending older than 72h ≈ 3 business days). Fresh pending is normal async settlement and is shown as a neutral per-CN badge, not an alert. The aged-pending arm is also the cheap backstop for a never-delivered terminal webhook (there is no refund poll).

  • A refund that fails asynchronously now becomes failed and surfaces; the false-success state is closed.
  • The refund leg stays operator-retried (RetryRefund), not auto-swept — money-out is conservative; the webhook + alert make stuck refunds visible.
  • Honest UI: succeeded should read as “refund issued / on its way”, since even Stripe’s succeeded is “submitted”, not “on the statement”.
  • Refund-poll / GetRefund reconciler for never-delivered webhooks — the aged-pending alert is its stand-in. Trigger: an observed missed webhook.
  • Dedicated RefundCanceled enum + CHECK-widening migration (0124) — trigger: a partner actually hits canceled refunds.
  • Ingesting external (dashboard) refunds into the credit ledger — larger separate decision; trigger: a tenant relies on dashboard refunds.
  • #319 (the refund “needs attention” alert this co-refines).
  • ADR-061 (atomic creditnote.Issue()); ADR-040 (webhook outbox / dedup spine).

Amendment (2026-08-04): retry convergence cannot rest on the idempotency key

Section titled “Amendment (2026-08-04): retry convergence cannot rest on the idempotency key”

An adversarial architecture review of the refund model found the original retry contract — “same hardcount_cn_<id> key as Issue(), Stripe dedups, so a retry converges on the original refund” — resting on a premise Stripe does not provide: v1 idempotency keys expire after ~24 hours, while this ADR’s own needs-attention window tells the operator to act at 72 hours. Two shipped defects followed:

  1. Double-refund window. Retrying a stuck partial pending refund past the key horizon minted a second live refund (Stripe’s amount_too_large protects only full-amount refunds), and the persist’s overwrite of stripe_refund_id orphaned the first refund’s webhooks as “foreign”.
  2. Truth regression. Within the horizon, a key replay returns the SAVED first response — so a retry after a webhook-recorded terminal failed re-persisted the stale create-time pending through the operator writer, which (unlike the webhook writer) had no monotonic guard. Event-id dedup then swallowed any correcting redelivery forever.

The amended contract — reconcile, adopt, only then create:

  • Every refund create stamps metadata hardcount_cn_id. Durable convergence is the metadata, not the key: a refund whose response was lost is always findable again.
  • RetryRefund with a stored id calls GetRefund first — a read, which always returns current truth, never a replay. succeeded → recovered (this also collects the missed-webhook case, the stand-in this ADR deferred the poller for); pending → refuse to mint a second refund while one is in flight; failed → a new refund is legitimate.
  • With no stored id, or past a provider-confirmed-dead one, FindRefundForCreditNote searches the PaymentIntent’s refunds by metadata (excluding the dead id) and adopts a live match instead of creating — ADR-108’s search-and-adopt, applied to money-out.
  • The create key scopes to the CN’s state generation (hardcount_cn_<id>_r_<hash(refund_id|status)>): concurrent retries share a snapshot and collapse into one refund at Stripe, while a retry after a persisted identity/status change gets a fresh key instead of replaying a dead attempt’s saved response. updated_at is deliberately NOT in the hash — the finder review showed the reconcile’s own row-touch would have split two close-together clicks into two keys, re-opening the double.
  • Adoption is live-only: a provider-failed twin is never adopted. The finder review caught an exclude-one-id variant livelocking between two dead twins — each click adopting the other dead refund and never reaching the create leg. Dead attempts remain in the audit trail.
  • Every status persist is identity-CAS’d (the writer names the refund id its snapshot held; the store skips stale writers), and the same-identity regression guard has a ProviderRead bypass: a fresh read — unlike a webhook delivery — cannot be stale, so it may correct a locally-misstamped failed upward. A same-value persist no longer touches updated_at, because re-confirming “still pending” is continued stuckness and resetting the 72h attention clock on the operator’s own poke hid exactly the rows the alert exists for.
  • refund_status=none on an issued CN with a refund allocation is now retry-eligible: that state is the Issue-crash window (leg ran, persist died), and the adoption lane recovers precisely it.
  • The operator writer now refuses same-identity regressions (failed is absorbing; succeeded yields only to failed) while still emitting the audit row — the action is the fact — and a NEW identity may write any status, because a fresh attempt legitimately restarts the lifecycle. Same-value persists still touch updated_at (the 72h window resets on a fresh provider confirmation).
  • Stripe’s failure_reason now rides every failed transition’s audit metadata (webhook, reconcile, and create-error paths). Previously it lived only in process logs, so refund-failure forensics were unanswerable retroactively.

Also shipped with this amendment: credit_notes gained its first useful lookup indexes (migration 0169 — invoice_id, partial stripe_refund_id); the webhook match was previously a tenant-wide scan.

Transition note: refunds created before this amendment carry no hardcount_cn_id metadata, so the adoption search cannot see them. No environment holds such a row with a live lost refund (verified: the two pre-amendment failed CNs are walk fixtures with no provider refund at all), but a self-hosted upgrader with a stuck pre-amendment refund should reconcile it manually in the Stripe dashboard before clicking retry.

Unchanged: refunds stay operator-retried, never auto-swept; the refund remains a leg of the credit note (the review confirmed the peer set — Lago, Orb, Stripe Billing’s own recommendation — models it the same way, and rejected a freestanding refunds table); the deferred attempt-history table (invoice_refund_attempts, mirroring ADR-102’s charge attempts) keeps its trigger: a DP asking for refund observability, a second PSP, or dispute ingestion.