# Selling one-time purchases next to subscriptions without two billing systems

> Model a lifetime deal as a one-time price that grants a plan, record it in a purchases table with a monotonic status, and resolve access from subscriptions and purchases in one rule.

"Add a lifetime deal" sounds like a checkbox. Done carelessly it becomes a
second billing system: a different checkout, a different table, a different
access check, and bugs where the two disagree.

## One catalogue, three intervals

Give a price an interval of `month`, `year` or `one_time`, and let a one-time
price belong to a plan like any other:

```ts
{ slug: "pro", prices: [
  { id: "pro-monthly", interval: "month", amount: 1900 },
  { id: "pro-yearly", interval: "year", amount: 19000 },
  { id: "pro-lifetime", interval: "one_time", amount: 29900 },
] }
```

Buying `pro-lifetime` grants the Pro plan, with no end date. Every access
check keeps asking one question, "is this user on Pro or higher", and does not
care how they paid.

## Checkout differs by one flag

| | Subscription | One-time |
|---|---|---|
| Stripe Checkout `mode` | `subscription` | `payment` |
| Trial | `subscription_data.trial_period_days` | not allowed |
| Metadata goes on | `subscription_data.metadata` | `payment_intent_data.metadata` (refunds find it there) |
| Receipt | `invoice.paid` | `invoice_creation: { enabled: true }` so it also raises `invoice.paid` |
| Polar / Dodo / Lemon Squeezy | a recurring product | a one-time product (or a single-payment variant) |

Put `{ userId, planSlug, priceId }` in the metadata of the checkout and of
whatever it creates, so no webhook has to guess who bought what.

## "Completed" is not "paid"

Bank debits (ACH, SEPA, Boleto) complete the checkout days before the money
arrives. Record the purchase as `pending` and grant nothing until it is
`paid`. Stripe tells you with `checkout.session.async_payment_succeeded` or
`..._failed`; other providers send a paid order or payment event.

## A purchases table with a status that only moves forward

```sql
create table billing_purchases (
  id text primary key,
  user_id text not null,
  provider text not null,
  provider_order_id text not null,        -- Stripe session, Polar order, ...
  provider_payment_id text,               -- where refunds point
  price_id text, plan_slug text,
  status text not null,                   -- pending, failed, paid, partially_refunded, disputed, refunded
  amount integer not null, amount_refunded integer not null default 0,
  currency text not null,
  receipt_url text,
  created_at timestamptz not null default now(),
  paid_at timestamptz, refunded_at timestamptz,
  updated_at timestamptz not null default now()
);
create unique index on billing_purchases (provider, provider_order_id);
```

Webhooks arrive out of order and more than once. Write with one upsert whose
status can only move forward along that list:

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

A late "pending" can no longer overwrite "paid", and a redelivered "paid"
cannot undo a refund.

## One rule decides access

```
subscription = the entitled one (trialing, active, past_due), else none
purchase     = the paid or partially refunded one on the highest plan
access       = whichever of the two is on the higher plan; ties go to the purchase
```

Two cases need a decision:

- **Bought lifetime while subscribed.** Both are live and the subscription
  pays for nothing. Tell the customer and link the portal. Do not cancel it
  for them from a webhook: moving money nobody clicked is worse than a
  warning.
- **Subscribing while owning lifetime.** Refuse at checkout, with a message.
  So is buying the same lifetime twice.

## Test both paths end to end

Buy monthly, then lifetime, with the provider's test card. The billing page
should show the lifetime plan, the purchase with its receipt, and a warning
about the redundant subscription. Refund the one-time payment in the
dashboard and the plan should drop back.

---

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
