"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
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_databuilt 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 readsmetadataonly: a Payment Link fillsclient_reference_idfrom its URL, so anyone can put a user id there. invoice_creationis on. Without it a one-time payment has no invoice, so it never raisesinvoice.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.
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 hasrefunded: true(full) oramount_refunded > 0(partial).charge.dispute.created: the charge hasdisputed: true.
Both carry the charge, not the session. The path back:
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:
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:
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.