Refund Recovery
Refund authority lives in refund_attempts (migration 019). An attempt record is written before any PG call — unrecorded external refunds cannot exist (REFUND-1). Committed cap authority is SUM(amount_krw) over non-failed attempts.
Attempt lifecycle
attempted → confirmed (PG cancel confirmed + provider_ref) → succeeded (internal effects applied)
→ needs_review (uncertain/unattributed — resolve via recheck, never guess)
→ failed (terminal — key/attempt reusable)1
2
3
2
3
idempotency_key(client) — same key + same request hash replays the recorded outcome; different hash →409 IDEMPOTENCY_CONFLICT.provider_ref(PG transaction key) — UNIQUE per attempt; used to attribute provider cancels during reconcile.- A non-terminal attempt (
attempted/confirmed/needs_review) blocks new attempts on the same order (409 REFUND_IN_PROGRESS) — double-refund defense.
Recovery paths
| Situation | Action |
|---|---|
attempted stuck >5min (non-toss) | expire_stale_internal_attempts fails it automatically on next refund/reconcile — no external call existed |
attempted/needs_review (toss) | RECHECK_PROVIDER_REFUND op — GET /v1/payments/{key} only; re-attributes by transactionKey amount match; unattributed stays needs_review |
confirmed without internal effects | APPLY_CONFIRMED_REFUND_INTERNAL op — re-runs apply_refund_effects (ledger idem_key refund-{oid}-{attempt} makes double-apply impossible) |
| Webhook cancel ahead of refund | apply_provider_cancel records a confirmed attempt then applies internally |
Never retry a refund after a timeout without checking refund_attempts + PG state — the PG cancel may already have succeeded (double refund).
Evidence
refund.attempt, refund.apply, and (full refunds) license.revoke evidence records are chain-sealed per event — see audit-evidence.