Skip to content

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.

Next.js on Vercel2 min readships at docs/solutions/nextjs-vercel/one-time-purchases-vs-subscriptions.md

Tags: billing · one-time-payments · subscriptions · lifetime-deal · entitlements

"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:

{ 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

SubscriptionOne-time
Stripe Checkout modesubscriptionpayment
Trialsubscription_data.trial_period_daysnot allowed
Metadata goes onsubscription_data.metadatapayment_intent_data.metadata (refunds find it there)
Receiptinvoice.paidinvoice_creation: { enabled: true } so it also raises invoice.paid
Polar / Dodo / Lemon Squeezya recurring producta 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

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:

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.