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
- Buy the one-time price with a test card; access appears.
- Refund it in the provider's dashboard. The webhook marks the purchase
refundedand access goes. - Replay the original paid event from the dashboard. The purchase stays
refundedand the replay answers as a duplicate.