# Refunds and disputes should take access away, exactly once

> Which webhook carries a refund or a dispute on each payment provider, how to find the purchase it belongs to, and how to revoke access without a late event giving it back.

A lifetime deal that survives its own refund is a free product with extra
steps. So is one bought with a stolen card that gets charged back. Access
has to follow the money, and the money moves after checkout.

## Where each provider tells you

| Provider | Refund | Dispute | How to find the purchase |
|---|---|---|---|
| Stripe | `charge.refunded` (`charge.refunded` true, or `amount_refunded` above 0) | `charge.dispute.created` | the charge's `payment_intent`, then the Checkout Session for it |
| Polar | `order.refunded` (`status` refunded or partially_refunded) | handled by Polar as merchant of record | the order id |
| Dodo Payments | `refund.succeeded` (`is_partial`) | `dispute.opened` | the payment id |
| Lemon Squeezy | `order_refunded` (`refunded_amount` against `total`) | handled by Lemon Squeezy | the order id |

Stripe is the one that needs care: refund and dispute events carry a charge,
not your order. Store the PaymentIntent id on the purchase at checkout time
(and put your metadata on `payment_intent_data`), then look the session up by
it: `stripe.checkout.sessions.list({ payment_intent })`.

## Re-read, then write the whole state

Do not apply "a refund of $10" as a delta. Re-fetch the order or the charge
and write what it says now: status, amount refunded, when. Applying the
current state is idempotent: the same event twice writes the same row, and a
missed event is fixed by the next one or by a nightly reconcile.

## Status only moves forward

Rank the statuses and let the store refuse to go backwards:

```
pending < failed < paid < partially_refunded < disputed < refunded
```

A redelivered `paid` that lands after the refund now changes nothing. The one
case this gets wrong: a dispute you later win stays `disputed`. It is rare;
fix that row by hand, or add a `dispute won` event that writes `paid` through
a separate path.

## Decide what each status grants

| Status | Access |
|---|---|
| paid | yes |
| partially_refunded | yes: a goodwill partial refund is not a cancellation |
| disputed | no, from the moment the dispute opens |
| refunded | no |
| pending, failed | no |

Keep that in one function that every access check calls. Then the pricing
table, the gate on a server action and the admin panel agree, because none of
them decides on its own.

## Subscriptions are different

A refunded subscription invoice does not end the subscription; the
subscription's own status does. Let the provider's subscription events drive
access there (`canceled`, `unpaid`, `expired` revoke) and treat invoice
refunds as bookkeeping.

## Test it

1. Buy the one-time price with a test card; access appears.
2. Refund it in the provider's dashboard. The webhook marks the purchase
   `refunded` and access goes.
3. Replay the original paid event from the dashboard. The purchase stays
   `refunded` and the replay answers as a duplicate.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
