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