Refund & Chargeback Reconciliation: The Payment-Reversal Gate
Most of the pipeline is built around a single optimistic assumption — that a succeeded payment stays succeeded. It does not. A ticket gets refunded days after the attendee row was confirmed, or a cardholder files a dispute and the acquirer claws the funds back weeks later, sometimes after a badge has already printed and access has already been granted. This stage exists to break that optimism safely: it is one of the four stages owned by the Registration Ingestion & Payment Reconciliation section, and its narrow job is to take a reversed payment event, walk the affected registration backward through its state machine, and emit a revocation that downstream systems act on before the attendee walks up to a scanner. The forward path — verifying and idempotently acknowledging a fresh payment — is owned by payment webhook handling; this stage is its mirror image, and it inherits the same discipline: verify, deduplicate, transition, delegate.
The failure this component prevents is a paid-looking badge that is no longer paid for. When a charge.refunded or charge.dispute.created arrives, the money has already left; the only open question is whether the physical and logical artifacts that money bought are still valid. A reversal that is dropped, processed twice, or races the print job produces one of two bad outcomes — an attendee who was refunded still scans in, or a legitimate attendee is revoked twice and their access is corrupted. Everything upstream that turns raw gateway traffic into a confirmed row belongs to async batch processing; everything on the print floor that acts on a revocation — pulling a queued job or invalidating a printed credential — belongs downstream to badge reprint handling. This stage owns only the seam between them: the deterministic translation of a reversed payment into a registration-state transition and a single, idempotent revocation signal. This page covers the two most common ways that translation goes wrong — revoking badges after a Stripe refund or chargeback and reconciling partial refunds against multi-ticket orders.
Scope Boundary Link to this section
The gate is fast to reason about only because its remit is deliberately small. A reversal is a financial fact that has already happened; this stage translates that fact into internal state and one downstream command, and delegates everything else.
| In-Scope (this component owns) | Out-of-Scope (delegated to adjacent stages) |
|---|---|
Mapping charge.refunded / charge.dispute.created onto a canonical PaymentReversal contract |
Verifying and acknowledging forward payment webhooks — owned by payment webhook handling |
Idempotently transitioning the linked registration from confirmed to refunded / disputed |
Draining and replaying reversals that could not be linked — owned by async batch processing |
Resolving the payment_intent → registration → credential binding |
Defining the multi-ticket order shape that binding relies on — owned by the event taxonomy schema design |
Emitting exactly one RevocationCommand per credential per reversal |
Cancelling a queued print job or reprinting on a valid reversal — owned by badge reprint handling |
| Allocating a partial refund to specific ticket line items | Deciding which physical badges to reclaim on-site — an operational, not code, concern |
| Emitting a structured reversal envelope with a correlation ID | Rendering or re-rendering the badge PDF itself — owned by badge generation |
The gate performs no charge lookups beyond the introspection call, holds no long-lived state, and never itself cancels a print job — it emits a command and trusts the downstream consumer to act idempotently. That separation is what lets a reversal be replayed months later during a dispute-representment audit and produce the identical verdict.
Data Contract & Schema Link to this section
A reversal enters as a raw gateway event with dozens of fields; only a handful matter, and they must be pinned. The PaymentReversal model below is the canonical shape every reversal is coerced into before any state changes, using Pydantic v2 with coercion disabled so a malformed reversal fails at the boundary rather than half-transitioning a registration.
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic_core import PydanticCustomError
REVERSAL_EVENTS = {"charge.refunded", "charge.dispute.created"}
class PaymentReversal(BaseModel):
"""Canonical, provider-neutral shape for a reversed payment."""
model_config = ConfigDict(strict=True, extra="ignore")
event_id: str = Field(..., min_length=8, max_length=64)
event_type: str
payment_intent: str = Field(..., pattern=r"^pi_[A-Za-z0-9]+$")
amount_reversed: Decimal = Field(..., ge=0)
currency: str = Field(..., pattern=r"^[a-z]{3}$")
reason: Optional[str] = None
@field_validator("event_type", mode="before")
@classmethod
def known_reversal(cls, v: str) -> str:
if v not in REVERSAL_EVENTS:
raise PydanticCustomError(
"unknown_reversal", "event_type must be a refund or dispute"
)
return v
@property
def is_dispute(self) -> bool:
return self.event_type == "charge.dispute.created"
Each field earns its place. event_id is the idempotency key threaded through the state transition and every log line, so it is bounded and never defaulted. event_type is pinned to the closed REVERSAL_EVENTS set by a mode="before" validator — an unrecognized reversal type must fail loudly rather than silently transition a registration to a state no downstream consumer understands. payment_intent is the join key back to the registration; it carries the provider’s pi_ shape so a truncated or spoofed id is rejected structurally. amount_reversed is a Decimal, never a float, because a partial refund of $40.00 against a $100.00 order must reconcile to the cent — floating-point drift here is how the wrong badge gets revoked. is_dispute is exposed as a property because the two event types demand different urgency: a refund is voluntary and reversible, a dispute freezes funds and starts a representment clock, and the state machine treats them as distinct terminal states.
Deterministic Implementation Link to this section
The handler chains three stages in a fixed order — introspect, transition, revoke — and is idempotent at every one. The load-bearing property is that re-running the whole handler with the same PaymentReversal produces byte-identical side effects: at most one state transition and at most one emitted RevocationCommand. That is enforced by claiming the event_id atomically before the transition and by guarding the transition on the registration’s current state, so a redelivered reversal against an already-refunded row is a no-op rather than a double-revoke.
import logging
from dataclasses import dataclass
import redis
logger = logging.getLogger("refund.reconciliation")
redis_client = redis.Redis(decode_responses=True)
# terminal reversal states, keyed by event type
REVERSAL_STATE = {
"charge.refunded": "refunded",
"charge.dispute.created": "disputed",
}
REVERSIBLE_FROM = {"confirmed", "checked_in", "printed"}
IDEMPOTENCY_TTL = 60 * 60 * 24 * 400 # cover the full chargeback window (~13 months)
@dataclass(frozen=True)
class RevocationCommand:
credential_id: str
registration_id: str
reason: str
kind: str # "refund" | "dispute"
def reconcile_reversal(reversal: "PaymentReversal", store, bus) -> Optional[RevocationCommand]:
"""Idempotently transition a registration and emit at most one revocation."""
# Stage 1 — claim the event id; a redelivery is a cheap, safe no-op.
claim = f"reversal:evt:{reversal.event_id}"
if not redis_client.set(claim, "1", nx=True, ex=IDEMPOTENCY_TTL):
logger.info("reversal_duplicate", extra={"event_id": reversal.event_id})
return None
# Stage 2 — resolve the binding and guard the transition on current state.
reg = store.get_registration_by_intent(reversal.payment_intent)
if reg is None:
redis_client.delete(claim) # let it be retried once the link exists
raise LookupError(f"no registration for {reversal.payment_intent}")
target = REVERSAL_STATE[reversal.event_type]
if reg.state == target:
logger.info("already_reversed", extra={"registration_id": reg.id})
return None
if reg.state not in REVERSIBLE_FROM:
logger.warning(
"non_reversible_state",
extra={"registration_id": reg.id, "state": reg.state},
)
return None
store.transition(reg.id, from_state=reg.state, to_state=target)
# Stage 3 — emit exactly one revocation; downstream consumers act idempotently.
command = RevocationCommand(
credential_id=reg.credential_id,
registration_id=reg.id,
reason=reversal.reason or reversal.event_type,
kind="dispute" if reversal.is_dispute else "refund",
)
bus.publish("credential.revoke", command)
logger.info(
"revocation_emitted",
extra={"registration_id": reg.id, "credential_id": reg.credential_id,
"to_state": target, "event_id": reversal.event_id},
)
return command
if __name__ == "__main__":
# In-memory doubles prove the idempotency guarantee end to end.
class _Store:
def __init__(self):
self.regs = {"pi_abc": type("R", (), {
"id": "reg_1", "credential_id": "cred_1",
"state": "printed"})()}
self.transitions = 0
def get_registration_by_intent(self, pi):
return self.regs.get(pi)
def transition(self, rid, from_state, to_state):
self.transitions += 1
self.regs["pi_abc"].state = to_state
class _Bus:
def __init__(self):
self.published = []
def publish(self, topic, cmd):
self.published.append((topic, cmd))
store, bus = _Store(), _Bus()
rev = PaymentReversal.model_validate({
"event_id": "evt_ref_001", "event_type": "charge.refunded",
"payment_intent": "pi_abc", "amount_reversed": "100.00",
"currency": "usd", "reason": "requested_by_customer",
})
first = reconcile_reversal(rev, store, bus)
second = reconcile_reversal(rev, store, bus) # redelivery
assert first is not None and second is None # only the first acts
assert store.transitions == 1 # exactly one transition
assert len(bus.published) == 1 # exactly one revocation
assert store.regs["pi_abc"].state == "refunded"
print("OK:", bus.published[0][1])
Three properties keep this safe under concurrency. The idempotency claim is atomic (SET ... NX EX), so two workers racing the same redelivery cannot both transition. The transition is guarded on the observed current state, so the operation is a fold, not a blind write — replaying it never double-decrements or resurrects a stale state. And the claim is released on an unresolved binding (redis_client.delete), because a reversal that arrives before its registration link exists is a transient failure that must be retried, not a permanent one that should be permanently suppressed.
Production Debugging & Observability Link to this section
A reversal that goes wrong is invisible until an attendee is refused or wrongly admitted at the door, so the diagnostics must ride on the log line and the correlation id. Every reversal emits a structured event keyed by event_id and payment_intent, which correlates back to the original payment webhook handling confirmation and forward to the revocation the print floor acts on.
{
"level": "info",
"event": "revocation_emitted",
"event_id": "evt_ref_001",
"payment_intent": "pi_abc",
"registration_id": "reg_1",
"credential_id": "cred_1",
"reversal_kind": "refund",
"to_state": "refunded",
"amount_reversed": "100.00"
}
In a Datadog or ELK pipeline, index these fields by name: payment_intent as the cross-stage correlation facet linking the forward payment to its reversal, reversal_kind as a monitored dimension (a rising dispute rate is a fraud or delivery-quality signal, not a routine-refund signal), to_state to confirm transitions are landing, and amount_reversed to catch a partial-refund allocation that silently revoked a full order. A non_reversible_state warning is the earliest sign of an ordering bug — a reversal arriving for a registration that was never confirmed, or one that already refunded. Reversals that raise LookupError because the payment_intent has no registration are routed to the dead-letter queue rather than dropped; the quarantine envelope carries the event_id and payment_intent so the reversal can be replayed once the binding exists. A saturation alert on count(event:non_reversible_state) / count(event:revocation_emitted) crossing a few percent is the earliest signal that reversals are racing their own confirmations.
Performance & Memory Constraints Link to this section
Reversal traffic is low-volume and bursty — a batch cancellation or a fraud wave arrives as a sudden burst — so the constraints are correctness under redelivery and store pressure, not throughput.
| Component | Constraint | Mitigation |
|---|---|---|
| Redis idempotency store | Keys must live for the full ~13-month chargeback window, inflating memory | Use a long EX TTL, maxmemory-policy noeviction, and monitor used_memory; disputes are rare so absolute key count stays bounded |
| State-transition writes | A refund wave (event cancelled) redelivers thousands of reversals at once | Guard on current state so redeliveries are no-ops; cap worker concurrency so the transition store is not stampeded |
| Binding lookup | get_registration_by_intent is a hot indexed read under a fraud burst |
Index on payment_intent; cache the negative result briefly so an unlinkable reversal storm does not hammer the primary |
| Revocation bus | Fan-out to print queue and access control can amplify a partial-refund bug | Emit one command per credential, make consumers idempotent on credential_id, and cap bus retry to avoid revocation storms |
| Dispute clock | A charge.dispute.created starts a representment deadline the pipeline must not miss |
Tag to_state:disputed transitions with the deadline and alert well before it; never auto-refund a disputed charge |
Incident Triage Checklist Link to this section
When a “refunded attendee still scanned in” or “wrongly revoked” report lands, work these steps in order. Target MTTR is under fifteen minutes.
- Confirm the reversal class. Group recent reversals by
reversal_kind:redis-cli --scan --pattern "reversal:evt:*" | wc -lagainst the gateway’s refund/dispute count for the window. A count far below the gateway total means reversals are being dropped before they claim a key. - Check the transition landed. For the disputed attendee, resolve the intent and read state:
psql -c "SELECT id, state FROM registrations WHERE payment_intent='pi_abc';". Aconfirmed/printedstate means the transition never fired; arefundedstate means the revocation is the missing link. - Inspect the quarantine depth.
redis-cli -n 4 LLEN dlq:reversal:unlinked. A climbing depth means reversals are arriving before their registration bindings — an ordering problem, not a logic bug — peek the newest withLINDEX dlq:reversal:unlinked 0. - Confirm the revocation was consumed. Check that the print queue and access-control consumers acted: a revocation emitted but not consumed is the classic “still scans in” cause, and it hands off to badge reprint handling for the credential-invalidation half.
- Contain, then replay. If reversals are racing confirmations, pause the reversal consumer briefly (
redis-cli SET reversal:pause 1 EX 600) so forward confirmations drain first, then hand the quarantined reversals to the async batch processing drain job to replay through the now-consistent state.
Frequently Asked Questions Link to this section
Should a chargeback and a refund transition to the same state?
No — keep them distinct. A refund is voluntary and final; a dispute freezes funds and opens a representment window where you may still win the charge back, so its registration must be recoverable to confirmed if you prevail. Collapsing both into a single reversed state throws away the information the door staff and the finance team need, and it makes the dispute deadline invisible. Model refunded and disputed as separate terminal states and let the revocation carry the reversal_kind.
Why guard the transition on the registration’s current state instead of just setting refunded?
Because reversals are redelivered and can arrive out of order relative to their own confirmation. A blind write would let a late reversal overwrite a state it should not touch, or let a redelivery re-emit a revocation and double-invalidate a credential. Guarding on the observed state makes the operation idempotent — an already-refunded row is a no-op, and a row that was never confirmed fails loudly into quarantine instead of silently corrupting state.
What if the reversal arrives before we ever confirmed the registration? Treat it as a transient failure, not a permanent one. Release the idempotency claim and route the reversal to the dead-letter queue so it is replayed once the forward confirmation lands. Permanently suppressing it — by keeping the claim and dropping the event — is how a refunded attendee ends up with a valid badge: the binding appears seconds later and the reversal that would have revoked it is already gone.
Related Link to this section
- Registration Ingestion & Payment Reconciliation — the parent section that frames how this reversal gate connects to ingestion, forward reconciliation, and badge handoff.
- Payment Webhook Handling — the forward stage whose confirmations this gate mirrors and reverses; it owns the signature and idempotency contract this stage reuses.
- Async Batch Processing — owns the dead-letter queue that drains and replays reversals this gate could not bind to a registration.
- Badge Reprint Handling — the downstream stage that consumes the revocation command to cancel queued jobs and invalidate a printed credential.
- Revoking Badges After a Stripe Refund or Chargeback — the provider-specific guide to the exact
charge.refunded/charge.dispute.createdhandler this stage defines. - Reconciling Partial Refunds Against Multi-Ticket Orders — the guide to allocating a partial refund to specific ticket credentials rather than revoking a whole order.
Up: Registration Ingestion & Payment Reconciliation — the section this reversal stage sits inside.