Skip to content

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.

A verified Stripe dispute starts a separate workflow that records a strike and credit note. Before service it may start a replacement bidder workflow, during service it installs a house ad, and after service it records the reversal without reclaiming the slot.A verified Stripe dispute starts a separate workflow that records a strike and credit note. Before service it may start a replacement bidder workflow, during service it installs a house ad, and after service it records the reversal without reclaiming the slot.

Chargeback invariants

  • Two independent saga instances: auction_id (main) and dispute:${dispute_id} (this one). Never collide.
  • The won slot’s public surface is daily_spotlight (D1), not the DO WON_SLOT record (server-only). So the reclaim’s job is to keep the disputed ad out of daily_spotlight: pre-serve the supersede marker makes MAIN’s T+24h activate resolve to SUPERSEDED (it never writes the winner row), and MAIN self-heals to a house ad via its own HOUSE_AD path; mid-serve the DisputeSaga overwrites the existing daily_spotlight row (PK = date) with a house ad immediately. deactivate itself never mutates the WON_SLOT record (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 DO activate, so it is never superseded). When a #421 runner-up respawn is eligible the marker is instead scoped to the disputed respawnUserId, so only MAIN’s activate is superseded (respawned: true → it stands down with no house) and the reclaim saga’s runner-up activate (a different user) proceeds to LIVE.
  • 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, never refund_amount) and emits the §14 correction PDF. The reclaim (#383b) therefore issues no second reversal — for the ORIGINAL saga, SUPERSEDED skips REFUND. A charged reclaim saga superseded without a respawn (#448) instead routes SUPERSEDED → REFUND → HOUSE_AD, where the guarded REFUND issues a full-reversal refund_amount credit 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 → REFUND arm (asset-gone, or a #390 creative-revoked-at-activation) issues a full-reversal refund_amount credit note on the winner’s undisputed charge — but it shares the exact refund step the reclaim arm guards, so it carries the same skipIfAlreadyReversed pre-check. A charge.dispute.created can land on that charge in the window after activate returns failed/revoked (which the pre-activate superseded marker cannot see); the DisputeSaga then reverses the invoice out-of-band, so an unguarded second refund_amount note would be rejected charge_disputed and 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 throws charge_disputed, the un-memoised step retries, and a later attempt finds the landed note and skips to HOUSE_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-serve pull, when the disputed bidder is known AND now < T+24h - 1h AND another ranked bidder exists, the DisputeSaga spawns a fresh AuctionSaga (${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 carries saga_instance_id = the reclaim instance id so 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 (returns respawn: false), so the first reclaim’s runner-up self-heals to a house ad rather than serving a now-also-charged-back bidder (its #448 SUPERSEDED → REFUND guard 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).