# Refunds and chargebacks on Dodo, and what they should do to access

> A refund or dispute webhook says which payment, not what was bought. Re-read the payment, derive the status from all its refunds and disputes, and never let a late event undo a refund.

The first refund is easy. Someone asks, you click refund in the Dodo dashboard,
they get their money. The question is what your app does next. If it does
nothing, a refunded lifetime customer keeps the product for free, and so does
anyone who learns that a chargeback costs them nothing.

## What Dodo tells you

| Event | Payload | Useful fields |
|---|---|---|
| `refund.succeeded` | a Refund | `payment_id`, `amount`, `is_partial` |
| `dispute.opened` | a Dispute | `payment_id`, `dispute_status`, `dispute_stage` |
| `dispute.lost` | a Dispute | the same, `dispute_lost` |

Neither payload says which product was bought, which user it was, or whether
this is the second partial refund on the same payment. The payment knows all
of that. So the handler does one thing first: re-read the payment.

```ts
const payment = await dodo.payments.retrieve(event.data.payment_id);
```

The payment carries `refunds` (each with a status and an amount), `disputes`
(each with a status), `refund_status` (`partial` or `full`), the product in
`product_cart`, and our checkout metadata. The status follows from those,
whatever order the events arrived in:

```ts
if (refunded >= payment.total_amount) return "refunded";
if (disputes.some(isLive)) return "disputed";   // opened, challenged, accepted, lost, expired
if (refunded > 0) return "partially_refunded";
return "paid";
```

Only refunds with `status: "succeeded"` count. A pending or failed refund did
not move money.

## Why derive, not increment

The tempting version adds each refund's amount to a running total. It breaks
the first time Dodo retries a delivery: the same refund is counted twice, and a
$50 goodwill credit becomes a $100 one that looks like a full refund. Deriving
the total from the payment's own list of refunds gives the same answer however
many times the event arrives.

The idempotency ledger (`billing_processed_events`, keyed on `webhook-id`)
stops most duplicates before they run. Deriving the state stops the rest.

## Status only moves forward

Purchases are written by one SQL upsert, shared by both ORMs, with a rank:

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

A write never lowers the rank. So a `payment.succeeded` retried a day late
cannot turn a refunded purchase back into a paid one, and a refund always wins.
`amount_refunded` keeps the larger value; `refunded_at` keeps the first one.

The known limit: a dispute you later win stays `disputed`. Winning is rare
enough that fixing the row by hand is the right trade:

```sql
update billing_purchases set status = 'paid' where provider_order_id = 'pay_...';
```

## What each status does to access

- `paid`, `partially_refunded`: the plan is granted. A partial refund is
  usually a goodwill credit, not a cancellation.
- `refunded`, `disputed`: nothing is granted. The UI shows the purchase with
  its status, so the customer sees why.

Refunds on subscription payments change nothing here. A subscription's access
follows its own status, and Dodo moves that with `subscription.*` events if the
refund comes with a cancellation.

## Bookkeeping

This repo records what access someone has, not your accounts. Dodo is the
merchant of record: it holds the tax, the invoices, the payouts and the
reconciliation reports. For revenue, fees and refunds by period, use Dodo's
reports, not a sum over `billing_purchases`, which stores gross amounts in the
customer's currency and nothing about fees or tax.

## Testing it

1. Buy the lifetime price in test mode (or send a signed fixture with
   `bun run dodo:test-webhook -- --user <id>`).
2. Refund half of it in the dashboard. The row goes `partially_refunded`,
   access stays.
3. Refund the rest. The row goes `refunded`, `/billing` drops the plan.
4. Resend the first `refund.succeeded` from the dashboard. It answers
   `duplicate`, and the row does not change.

---

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
