One-time products
Sell non-recurring items - add-ons, lifetime deals, credit packs - through the products block of pricingConfig, with ownership, purchase limits, and stock caps.
Non-recurring purchases declared in the products block of pricingConfig (packages/config/src/pricing.ts), a separate named export from @repo/config rather than a key on config. Products grant ownership instead of a recurring entitlement, so they never change user.plan.
Enabling products
pricingConfig.products ships { enabled: false, items: [] }. Items render and check out only when config.payment.enabled and pricingConfig.products.enabled are both true - flip enabled back to false to hide every product without deleting its item.
// packages/config/src/pricing.ts
products: {
enabled: true,
items: [
{
id: "addon_api",
name: "pricing.products.item_4.name", // i18n key
description: "pricing.products.item_4.description",
stripePriceId: "price_...",
polarProductId: "...",
amounts: { USD: 99, EUR: 89 }, // major units, keyed by currency
purchaseLimit: 5, // max per user
maxStock: 10, // global cap
},
],
},ProductItem fields
| Key | Type | Required | Description |
|---|---|---|---|
id | string | yes | Stable identifier used in code and storage. |
name | string | yes | Display name (use an i18n key, as in the example). |
description | string | no | Description (i18n key, as in the example). |
amounts | Record<string, number> | yes | Price per currency code in major units (e.g. { USD: 99 }). |
stripePriceId | string | no | Stripe Price ID for this product. |
polarProductId | string | no | Polar Product ID for this product. |
credits | number | no | Credits granted on purchase - turns it into a credit pack. |
purchaseLimit | number | no | Max units a single user may own. undefined = unlimited. |
maxStock | number | no | Hard global cap on units sold across all buyers. undefined = unlimited. |
restrictToPlans | string[] | no | Only allow purchase for users on these plan ids. |
tracked | boolean | no | Record ownership in ownedProducts. Implicitly true when purchaseLimit or maxStock is set. |
featured | boolean | no | Highlight the item in product listings. |
Provider refs are direct fields, so one product maps to either provider with no code changes. Every amounts map must price config.currency.base - see Plans and pricing.
tracked: true, purchaseLimit, or maxStock is set (isProductTracked). An untracked product - a credit pack that only sets credits, say - grants its credits but writes no ownedProducts row, so ownsProduct() returns false for it. Set tracked: true when you need to query ownership.Ownership model
Tracked purchases are recorded in ownedProducts, keyed by the paying user: the active organization's owner when one is active, otherwise the session user. An add-on bought inside an organization is therefore owned by the owner and applies to every member working there.
- One row per
(entityId, productId)holds the ownedquantity. purchaseLimitcaps units per user;maxStockcaps units globally. Both are hard - a paid delivery past either one is refused, never granted.- Webhooks keep
ownedProductsin sync after checkout - see Background jobs. - Both limits are checked at checkout and re-checked when the webhook fires, since parallel checkout tabs can slip past the first guard.
- A refunded unit returns to
maxStock: stock counts what customers hold, not what has ever been charged for. maxStockis enforced against aproductStockcounter row and the liveownedProductssum, whichever is higher. EditingownedProductsby hand therefore cannot reopen a spent drop - raisemaxStockinstead.
A purchase that reaches the webhook past purchaseLimit or maxStock is refused but not auto-reversed: the money arrived, so the sale is logged as revenue with zero credits granted and an error naming it for manual refund. Refunding that charge returns the money only - no credits clawed, no owned unit revoked, since it granted neither; refunding the customer's legitimate charge still claws back the credits it granted and revokes the unit. Each reversal is matched to its own payment.
Querying ownership
Construct Billing from a session, then await the entitlement checks. All methods read local state kept in sync by provider webhooks.
import { Billing } from "@repo/payments";
const billing = await Billing(session);
await billing.ownsProduct("addon_api");| Method | Returns | Notes |
|---|---|---|
ownsProduct(id) | Promise<boolean> | true when owned quantity is 1 or more. |
getProductQuantity(id) | Promise<number> | 0 if none owned. |
getOwnedProductsWithQuantities() | Promise<OwnedProduct[]> | { productId, quantity } per tracked product. |
getOwnedProductIds() | Promise<string[]> | Ids of tracked products owned. |
Plans & Pricing
Define subscription tiers, per-currency amounts, and billing intervals in pricingConfig, and control how revenue and refunds are recorded.
Credits
Meter any paid feature with usage-based credits - per-plan grants, recurring re-grants, cost-priced debits, custom top-ups, and refund handling.