# Refunds and disputes on Polar, and taking access back exactly once

> A refund arrives as order.refunded with the whole order. A dispute arrives as nothing at all. How this repo revokes a lifetime deal in both cases, why partial refunds keep access, and why a late paid delivery cannot give 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 and charged back. Access has to
follow the money, and on a one-time purchase the money moves after checkout.

## Refunds: `order.refunded`

Refund an order in the Polar dashboard (or through the API) and Polar sends
`order.refunded` with the whole order as it now stands:

```json
{ "type": "order.refunded", "data": {
  "id": "5c1a8e2b-...", "status": "refunded", "billing_reason": "purchase",
  "total_amount": 32591, "refunded_amount": 29900, "refunded_tax_amount": 2691,
  "subscription_id": null, "metadata": { "userId": "user_ada", "priceId": "pro-lifetime" }
} }
```

The adapter writes that state, not a delta, onto the same purchase row the
`order.paid` created (keyed on the order id):

| Order | Purchase row | Access |
|---|---|---|
| `status` refunded, or refunded amounts reach the total | `refunded` | gone |
| `status` partially_refunded, or any refunded amount | `partially_refunded` | kept |

Note the tax. `total_amount` includes the tax Polar collected, and
`refunded_amount` does not; `refunded_tax_amount` is the rest. The row stores
`amount = total_amount` and `amount_refunded = refunded_amount +
refunded_tax_amount`, so a full refund reads as equal numbers.

A partial refund keeps access on purpose. It is usually a goodwill credit, not
a cancellation. If your policy says otherwise, refund in full.

A refund on a subscription order (`subscription_id` set) does not touch the
purchases table. The adapter re-reads the subscription and writes whatever
Polar says about it; cancelling or revoking is a separate action in Polar.

## Out of order, and twice

Polar retries failed deliveries and can deliver out of order. Two defences:

1. **The claim.** Each delivery is claimed as `polar:<webhook-id>` before any
   write. The same delivery twice answers `duplicate` and writes nothing.
2. **Status only moves forward.** The purchase upsert ranks
   `pending < failed < paid < partially_refunded < disputed < refunded` and
   never goes back. A slow `order.paid` that lands after `order.refunded` is a
   different delivery, so the claim lets it through, and the upsert still
   leaves the row `refunded`. `amount_refunded` only grows; `paid_at` and
   `refunded_at` keep their first value.

That is why the adapter can write the order exactly as delivered, without a
second call to Polar.

## Disputes: no webhook

Polar handles chargebacks as merchant of record, and the SDK this repo pins
has no dispute event. So nothing arrives when a customer disputes a lifetime
purchase. The nightly job closes the gap:

```bash
bun run billing:reconcile
```

Besides re-reading subscriptions and recent orders, it lists disputes that are
`needs_response`, `under_review` or `lost`, fetches each order, and writes its
purchase as `disputed`, which grants nothing. Won and prevented disputes change
nothing. Schedule it nightly; a dispute then costs at most a day of access.

A dispute you later win leaves the row `disputed`, because status never moves
back. Fix that row by hand (`status = 'paid'`) once Polar shows it won.

## What the customer sees

`/billing` reads the same rows. After a full refund the plan card falls back
to the free plan and the purchase history shows the refund. Nothing is emailed
from this app for a refund; the receipt for it is in Polar's customer portal.

## Test it

```bash
bun run billing:test-webhook -- --user <user id>                            # paid
bun run billing:test-webhook -- --user <user id> --order <order id> --refund  # refunded
```

Then with a real sandbox order: refund it in the dashboard and watch
`order.refunded` answer `200` and the plan leave `/billing`.

---

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
