Process 05 — chargeback handling
Process 05 — chargeback handling
Section titled “Process 05 — chargeback handling”charge.dispute.created arrives — may be same-day (slot not yet served), during-slot, or weeks later. Triggers a separate DisputeSaga so the main AuctionSaga is not blocked.
Chargeback invariants
- Two independent saga instances:
auction_id(main) anddispute:${dispute_id}(this one). Never collide. - The won slot’s public surface is
daily_spotlight(D1), not the DOWON_SLOTrecord (server-only). So the reclaim’s job is to keep the disputed ad out ofdaily_spotlight: pre-serve the supersede marker makes MAIN’sT+24hactivateresolve toSUPERSEDED(it never writes the winner row), and MAIN self-heals to a house ad via its ownHOUSE_ADpath; mid-serve the DisputeSaga overwrites the existingdaily_spotlightrow (PK =date) with a house ad immediately.deactivateitself never mutates theWON_SLOTrecord (a DO-local audit record). - The marker supersedes unconditionally in the #420 self-heal case (there is exactly one won slot per auction, so the DO needs no identity to scope it), and the house-ad replacement is written straight to
daily_spotlight(it never round-trips a DOactivate, so it is never superseded). When a #421 runner-up respawn is eligible the marker is instead scoped to the disputedrespawnUserId, so only MAIN’sactivateis superseded (respawned: true→ it stands down with no house) and the reclaim saga’s runner-upactivate(a different user) proceeds toLIVE. - Credit note (out-of-band reversal — #383) is always issued before the reclaim, and before strike (process ordering). On a chargeback Stripe does not refund — the card network already clawed the funds back; the credit note records the out-of-Stripe return (
out_of_band_amount, neverrefund_amount) and emits the §14 correction PDF. The reclaim (#383b) therefore issues no second reversal — for the ORIGINAL saga,SUPERSEDEDskipsREFUND. A charged reclaim saga superseded without a respawn (#448) instead routesSUPERSEDED → REFUND → HOUSE_AD, where the guardedREFUNDissues a full-reversalrefund_amountcredit note only when the runner-up’s invoice carries no credit note yet (the #432 scope window — genuinely charged-never-served); when the runner-up was itself charged back (the #421 downgrade below), the DisputeSaga’s out-of-band note already exists and the guard skips. - ACTIVATE_FAILED refund is guarded too (#453): the
ACTIVATE_FAILED → REFUNDarm (asset-gone, or a #390 creative-revoked-at-activation) issues a full-reversalrefund_amountcredit note on the winner’s undisputed charge — but it shares the exactrefundstep the reclaim arm guards, so it carries the sameskipIfAlreadyReversedpre-check. Acharge.dispute.createdcan land on that charge in the window afteractivatereturnsfailed/revoked (which the pre-activatesupersededmarker cannot see); the DisputeSaga then reverses the invoice out-of-band, so an unguarded secondrefund_amountnote would be rejectedcharge_disputedand wedge the step. Unlike the reclaim arm (where the prior reversal is ordered to exist), here the dispute is fully concurrent, so the clean skip is the lucky interleaving and the residual race — note not yet committed — is the common path: the refund attempt throwscharge_disputed, the un-memoised step retries, and a later attempt finds the landed note and skips toHOUSE_AD. Both interleavings converge on no double reversal; the money-moving reversals stay mutually exclusive (refund-first → a later chargeback is network-rejected on an already-refunded charge). - Credit-note PDF archived to R2 alongside the original invoice; 10y retention.
- Runner-up respawn (#421): on a
pre-servepull, when the disputed bidder is known ANDnow < T+24h - 1hAND another ranked bidder exists, theDisputeSagaspawns a freshAuctionSaga(${auction_id}:reclaim:${dispute_id},excludeUserId= the disputed bidder) on the next ranked bidder instead of the house-ad self-heal — the runner-up would have paid. The reclaim’s invoice/PI metadata carriessaga_instance_id = the reclaim instance idso its webhook signals route back to it (not MAIN); its Stripe idempotency keys are namespaced by that id so they never collide with MAIN’s. The reclaim falls back to a house ad if every runner-up declines, so the slot is always served. The house-ad self-heal (above) still stands when the window has passed, no runner-up exists, or the bidder is unmapped. At most one reclaim per auction: a second, distinct same-day chargeback while the first reclaim is still in flight downgrades the supersede marker back to unconditional (returnsrespawn: false), so the first reclaim’s runner-up self-heals to a house ad rather than serving a now-also-charged-back bidder (its #448SUPERSEDED → REFUNDguard finds the runner-up’s invoice already reversed by that 2nd dispute’s out-of-band credit note and skips a second reversal) — the slot can host only one reclaim, and a fraud-saturated auction (multiple same-day chargebacks) correctly collapses to the house ad. - Post-served chargebacks are accepted as a pure loss + strike; covered explicitly in T&Cs (owned by
12-legal-compliance).