# One-time payments and subscriptions on Dodo Payments, side by side

> Dodo decides one-time or recurring from the product, and payment.succeeded fires for both. Tell them apart by the payment's subscriptions, store purchases separately, and let a refund take access back.

A lifetime deal is the easiest thing to sell and the easiest to get wrong. The
checkout is the same as a subscription's. The webhook is the same event. The
difference lives in two fields, and reading the wrong one hands out a lifetime
plan for a $19 monthly charge.

## Dodo models it on the product

Dodo carries one price per product, and the price has a type:

- `recurring_price`: a subscription product. Billed every month or year.
- `one_time_price`: a single payment product. Charged once.

So Pro monthly, Pro yearly and Pro lifetime are three products. The checkout
does not say which kind it is selling; the product does:

```ts
await dodo.checkoutSessions.create({
  product_cart: [{ product_id: "pdt_...", quantity: 1 }],
  customer: { customer_id },
  metadata: { userId, planSlug, priceId },
  return_url: `${origin}/billing?checkout=success`,
  cancel_url: `${origin}/pricing?checkout=cancelled`,
  // Subscriptions only: the catalogue's trial, 0 to override the product's.
  subscription_data: { trial_period_days: 7 },
});
```

Dodo copies `metadata` onto the payment and the subscription it creates, which
is how the webhook knows the user without guessing from an email.

## One event, two meanings

`payment.succeeded` fires for a one-time purchase, for a subscription's first
payment, and for every renewal. What separates them is on the payment:

```ts
function isOneTimePayment(payment) {
  const ids = [...(payment.subscription_ids ?? []), payment.subscription_id].filter(Boolean);
  return !payment.is_update_payment_method && ids.length === 0;
}
```

Two traps in that function:

- **`subscription_id` null is not enough.** A payment that starts several
  subscriptions at once leaves `subscription_id` null and lists them in
  `subscription_ids`. Dodo's own docs say to read the list.
- **A card change is a payment too.** When a subscriber updates their card,
  Dodo may take a zero charge with `is_update_payment_method: true`. It is not
  a purchase and deserves no receipt.

## Store them apart

A subscription is a row that changes: active, past due, cancelled. A one-time
purchase is a row that mostly does not: paid, then maybe refunded. Mixing them
in one table means every entitlement query needs a special case.

This repo keeps `billing_subscriptions` and `billing_purchases` separate. A
purchase is keyed on the Dodo payment id (Dodo has no separate order object)
and carries the product, amount, currency, the refunded amount and Dodo's
invoice URL. Access asks both:

```ts
import { hasPlan } from "@/lib/billing";

await hasPlan(user.id, "pro"); // a live subscription OR a paid lifetime purchase
```

A higher plan wins whichever way it was bought, so a Team subscriber who also
owns Pro lifetime is on Team.

## What to re-read and what to trust

For a subscription, always re-read it from Dodo before storing it. Retries run
for about a day, so an old `subscription.active` can land after the
`subscription.cancelled` that replaced it.

For a one-time payment, the signed `payment.succeeded` payload is the payment,
and it is safe to store as is, because the purchase upsert only moves status
forward (`pending < failed < paid < partially_refunded < disputed < refunded`).
A late "paid" can never undo a refund that was recorded first.

## Refunds take access back

A refund or a dispute names the payment, not the product. Re-read the payment:
it carries every refund and dispute so far, and the status follows from them.

| Payment state | Purchase status | Access |
|---|---|---|
| succeeded | `paid` | yes |
| part refunded | `partially_refunded` | yes |
| fully refunded | `refunded` | no |
| dispute open, accepted or lost | `disputed` | no |

A partial refund is usually a goodwill credit, not a cancellation, so it keeps
access. A dispute you later win stays `disputed` in this repo; fix that row by
hand.

## Buying lifetime while subscribed

It happens: someone on Pro monthly buys Pro lifetime. The checkout allows it
(the purchase is worth more than the subscription), and `/billing` then warns
that the subscription is paying for nothing, with a button to cancel it in
Dodo's portal. Nothing is cancelled automatically: money decisions stay with
the customer.

## Test both without a card

`bun run dodo:test-webhook -- --user <id>` signs a one-time
`payment.succeeded` with your webhook key, posts it to the running app, and
replays it. You should see `processed`, then `duplicate`, and the purchase on
`/billing`. Subscriptions need a real test-mode checkout, because every
subscription event is re-read from Dodo.

---

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
