A lifetime deal and a monthly plan feel like two prices of one thing. On Polar they are two products, they raise different webhooks, and they live in different tables. Get the mapping right once and the rest of the app never has to care.
One product, one price
Polar attaches a single price to each product, and the product carries the interval: recurring monthly, recurring yearly, or one-time. So this catalogue:
{ 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 },
] }
is three Polar products, and three env vars hold their ids:
BILLING_PRICE_PRO_MONTHLY, BILLING_PRICE_PRO_YEARLY,
BILLING_PRICE_PRO_LIFETIME. bun run billing:sync-plans creates them with a
price_id metadata key, so a product can always be traced back to its
catalogue price even before its env var is set.
One checkout for both
There is no separate "buy once" code path. createCheckout sends the product
id, our user id as externalCustomerId, and { userId, planSlug, priceId } as
metadata. Polar copies that metadata onto the order and, for a recurring
product, onto the subscription. The product decides what Polar sells.
Two details differ by kind:
- A trial only makes sense on a subscription.
trialDaysin the catalogue is sent as a trial in days; a recurring price without one sendsallowTrial: false, so a trial left on the product never applies silently. - The success page reads
checkout_idfrom its URL, checks the checkout belongs to the signed-in user, and writes what it finds. The webhook still does the real work; this only saves the buyer a wait.
Which webhook means "paid"
Every payment on Polar is an order, and order.paid fires when the money is
in. billing_reason tells you what kind:
billing_reason | subscription_id | What this repo does |
|---|---|---|
purchase | null | writes a billing_purchases row (status paid) |
subscription_create | set | re-reads the subscription |
subscription_cycle | set | a renewal: re-reads the subscription |
subscription_update | set | a plan change: re-reads the subscription |
Each one also returns a receipt when the amount is above zero, sent once inside the webhook's idempotency claim.
order.created is not "paid" and is not subscribed to. Neither is
checkout.updated: a succeeded checkout can precede the order by a moment.
Where each kind lives
- Subscriptions are rows in
billing_subscriptions, keyed on the Polar subscription id. Their state goes back and forth (active, past due, cancelled, revoked), and deliveries arrive out of order, so everysubscription.*delivery makes the adapter fetch the subscription fresh and write what Polar says now. - One-time purchases are rows in
billing_purchases, keyed on(provider, provider_order_id). An order only moves forward, and the upsert enforces it:pending < failed < paid < partially_refunded < disputed < refunded. So the signed order in the payload is written as delivered, and a lateorder.paidcan never undo a refund.
A subscription's plan comes from its current product only, never from the checkout metadata. When a customer switches from Pro to Team in the portal, the product changes and the metadata still says Pro.
When someone holds both
A customer on Pro monthly who buys Pro lifetime holds an entitled subscription
and an entitled purchase. getEntitlement picks the higher plan by catalogue
rank, and a tie goes to the purchase, because it never lapses. The billing page
then warns that the subscription is paying for nothing, with a button to the
portal. Nothing is cancelled automatically: that is the customer's call.
The reverse is blocked on purpose. While a subscription is entitled, a new subscription checkout answers "you already have a subscription": plan changes belong in the portal, where Polar prorates. A lifetime owner who tries to buy the same plan again hears "you already own this".
Gate on the plan, never the product
import { hasPlan, requirePlan } from "@/lib/billing";
await requirePlan("pro"); // subscription or lifetime, Team included
if (await hasPlan(user.id, "team")) {}
Nothing outside src/lib/billing/provider.ts knows a product id, a
billing reason or whether the access came from a renewal or a single payment.