# One-time payments with Stripe Checkout, from lifetime deal to refund

> A lifetime deal is a payment-mode Checkout Session, not a subscription. Which id to key it on, when it is really paid, how a receipt goes out, and how a refund or dispute takes the access back.

"Pay once, keep it for good" sounds simpler than a subscription. On Stripe it
is a different object with its own traps: no subscription to re-read, no
renewal to fix a missed event, and a refund that arrives on a charge two
objects away from anything you stored.

Here is the shape that holds up.

## Create it as a payment, with an invoice

```ts
const session = await stripe.checkout.sessions.create({
  mode: "payment",
  customer: customerId, // one Stripe customer per user, created before checkout
  line_items: [{ price: lifetimePriceId, quantity: 1 }],
  client_reference_id: user.id,
  metadata: { userId: user.id, planSlug: "pro", priceId: "pro-lifetime" },
  payment_intent_data: {
    // Refunds and disputes arrive on the charge, and the charge carries these.
    metadata: { userId: user.id, planSlug: "pro", priceId: "pro-lifetime" },
    description: "Pro, lifetime ($299.00)",
  },
  // A PDF invoice, an entry in the portal, and an `invoice.paid` event.
  invoice_creation: { enabled: true },
  success_url: `${origin}/billing?checkout=success&checkout_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${origin}/pricing?checkout=cancelled`,
});
```

Four choices in there are deliberate:

- **The price is a one-off Stripe price.** Not a recurring price with a
  one-time coupon, not `price_data` built from a number the browser sent. The
  amount lives in Stripe, and your catalogue's copy is checked against it.
- **The user id goes on everything.** On the session (`metadata.userId`) for
  the completion event, and on the PaymentIntent for the refund and dispute
  events, which never mention the session. The webhook reads `metadata` only:
  a Payment Link fills `client_reference_id` from its URL, so anyone can put a
  user id there.
- **`invoice_creation` is on.** Without it a one-time payment has no invoice,
  so it never raises `invoice.paid`, and a receipt path that works for
  renewals silently skips lifetime buyers. With it, one receipt handler covers
  both. Stripe bills post-payment invoices at the Invoicing rate (0.4% on
  Starter, capped at $2 per invoice).
- **A customer is passed in.** In payment mode Checkout otherwise creates a
  guest customer, and the buyer's lifetime purchase ends up detached from the
  customer that holds their subscription and saved card.

## Key it on the session, keep the PaymentIntent beside it

The obvious key is the PaymentIntent. It is the wrong one: a session fully
covered by a 100% coupon completes with no PaymentIntent at all. Key the
purchase on the Checkout Session id (`cs_...`), which always exists, and store
the PaymentIntent (`pi_...`) in its own column. That second column is how a
refund finds the row later.

```sql
create unique index billing_purchases_provider_order_idx
  on billing_purchases (provider, provider_order_id);   -- cs_...
create index billing_purchases_payment_idx
  on billing_purchases (provider_payment_id);           -- pi_...
```

## Completed is not paid

`checkout.session.completed` fires when the buyer finishes Checkout. For a
card that is also the moment of payment. For a bank debit (ACH, SEPA, Boleto)
the money arrives days later:

| Event | `payment_status` | Store it as | Access |
|---|---|---|---|
| `checkout.session.completed` | `paid` | `paid` | yes |
| `checkout.session.completed` | `no_payment_required` (100% coupon) | `paid` | yes |
| `checkout.session.completed` | `unpaid` (bank debit) | `pending` | no |
| `checkout.session.async_payment_succeeded` | `paid` | `paid` | yes |
| `checkout.session.async_payment_failed` | `unpaid` | `failed` | no |

Grant on `paid` only. Subscribe the endpoint to both async events, or every
bank-debit buyer stays `pending` forever.

## Refunds and disputes take the access back

A lifetime plan with no way to lose it is a free product for anyone who asks
their bank. Two events revoke it:

- `charge.refunded`: the charge has `refunded: true` (full) or
  `amount_refunded > 0` (partial).
- `charge.dispute.created`: the charge has `disputed: true`.

Both carry the charge, not the session. The path back:

```ts
const sessions = await stripe.checkout.sessions.list({ payment_intent: charge.payment_intent, limit: 1 });
const session = sessions.data[0]; // then re-read it with the charge expanded
```

Re-read the session with `payment_intent.latest_charge` expanded and derive
the status from the charge: refunded, then disputed, then partially refunded,
then paid. A partial refund keeping access is a product decision (a goodwill
credit is not a cancellation); a full refund or any dispute revoking it is not
negotiable.

## Make the status only move forward

Events arrive out of order and more than once. A late `completed` after a
`charge.refunded` must not bring the plan back. So the upsert ranks statuses
and never lets one move backwards:

```sql
on conflict (provider, provider_order_id) do update set
  status = case
    when array_position(array['pending','failed','paid','partially_refunded','disputed','refunded']::text[], excluded.status)
      >= coalesce(array_position(array['pending','failed','paid','partially_refunded','disputed','refunded']::text[], billing_purchases.status), 0)
    then excluded.status else billing_purchases.status end,
  amount_refunded = greatest(billing_purchases.amount_refunded, excluded.amount_refunded),
  paid_at = coalesce(billing_purchases.paid_at, excluded.paid_at),
  refunded_at = coalesce(billing_purchases.refunded_at, excluded.refunded_at)
```

The one case this gets wrong on purpose: a dispute you later win stays
`disputed`. Winning is rare, slow and worth a human look, so fix that row by
hand rather than teaching the ranking to go backwards.

## Show the plan before the webhook lands

The success URL carries `{CHECKOUT_SESSION_ID}`. On return, retrieve that
session, check its `metadata.userId` is the signed-in user (the URL is
user-controlled), and write the purchase straight away. Send no receipt from
there: that stays with the webhook, inside its idempotency claim. The webhook
still arrives and writes the same row, which the upsert makes harmless.

## One-time and subscriptions side by side

- **Buying lifetime while subscribed** is allowed. Afterwards the subscription
  pays for nothing, so say so on the billing page with a link to cancel it in
  the portal. Never cancel it automatically: the customer may have reasons.
- **Buying lifetime twice** should be refused before Checkout opens. A second
  identical purchase is a refund request with extra steps.
- **Higher plan wins.** A lifetime Pro and a Team subscription together mean
  Team.

## Recovery

Payment-mode sessions are not in `stripe.subscriptions.list()`, so a
subscription-only reconcile job never repairs a missed purchase or a missed
refund. Sweep recent completed sessions too:

```ts
for await (const session of stripe.checkout.sessions.list({
  status: "complete",
  created: { gte: since },
  limit: 100,
})) {
  if (session.mode === "payment") await resyncPurchase(session.id);
}
```

In a repo generated with this battery, `bun run billing:reconcile` does
exactly that for the last 90 days, through the same translation and upsert the
webhook uses.

---

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
