ADR-063: Refund status is reconciled from Stripe webhooks (async truth)
Date: 2026-06-28 Status: Accepted (amended 2026-08-04)
Summary
Section titled “Summary”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.
Context
Section titled “Context”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”).
Decision
Section titled “Decision”-
Record the create-time status faithfully.
CreateRefundreturns Stripe’s actual status (mapped to a Hardcountrefund_status);Issue()andRetryRefundrecordpendingwhen Stripe says pending, not a blanketsucceeded. (The common healthy card refund still returnssucceededsynchronously.) -
The webhook is the source of truth for the async outcome. Handle
charge.refund.updated,refund.updated, andrefund.failed(all carry a Refund object; one status-driven handler) — admitted viaendpointAuthoritativeEvent(refunds carry nohardcount_*metadata; the per-tenant endpoint + HMAC is authoritative). Match webhook→credit-note bystripe_refund_id; apply monotonically (terminal wins; a stale out-of-orderpendingnever 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. -
Status mapping (lossy, no migration). Stripe’s 5 states collapse into Hardcount’s 4-value
refund_statusCHECK:succeeded→succeeded,failed→failed,canceled→failed(money returned to platform — the operator-actionable bucket),pending→pending,requires_action→pending. Thecanceled→failedcollapse is the re-litigable call here. -
Co-refine the #319 alert (same change) so faithful
pendingdoesn’t flood it: “needs attention” =failedOR (pendingolder than 72h ≈ 3 business days). Freshpendingis 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).
Consequences
Section titled “Consequences”- A refund that fails asynchronously now becomes
failedand 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:
succeededshould read as “refund issued / on its way”, since even Stripe’ssucceededis “submitted”, not “on the statement”.
Deferred (named triggers)
Section titled “Deferred (named triggers)”- Refund-poll /
GetRefundreconciler for never-delivered webhooks — the aged-pending alert is its stand-in. Trigger: an observed missed webhook. - Dedicated
RefundCanceledenum + 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.
Related
Section titled “Related”- #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:
- Double-refund window. Retrying a stuck partial
pendingrefund past the key horizon minted a second live refund (Stripe’samount_too_largeprotects only full-amount refunds), and the persist’s overwrite ofstripe_refund_idorphaned the first refund’s webhooks as “foreign”. - Truth regression. Within the horizon, a key replay returns the SAVED
first response — so a retry after a webhook-recorded terminal
failedre-persisted the stale create-timependingthrough 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. RetryRefundwith a stored id callsGetRefundfirst — 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,
FindRefundForCreditNotesearches 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_atis 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
failedupward. A same-value persist no longer touchesupdated_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=noneon 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_reasonnow 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.