# One-time and subscription products on Polar, from lifetime deal to renewal

> Polar sells one price per product, so a plan sold monthly, yearly and for life is three products. How each kind checks out, which webhook proves it was paid, where it is stored, and how access resolves when a customer holds both.

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:

```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 },
] }
```

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. `trialDays` in the catalogue is
  sent as a trial in days; a recurring price without one sends
  `allowTrial: false`, so a trial left on the product never applies silently.
- The success page reads `checkout_id` from 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 every
  `subscription.*` 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
  late `order.paid` can 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

```ts
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.

---

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
