GenerateSaaS

One-time products

Sell single-purchase items with ownership checks via the products block of pricingConfig.

Non-recurring purchases (credit packs, add-ons, lifetime deals) declared in the products block of pricingConfig (packages/config/src/pricing.ts). They render and check out only when config.payment.enabled and pricingConfig.products.enabled are both true. pricingConfig.products ships { enabled: false, items: [] } - set enabled: true and add ProductItems to start selling.

Unlike plans, products grant ownership rather than a recurring entitlement - they never change plan. pricingConfig is a separate named export from @repo/config, not a key on config.

Declaring products

Each entry in pricingConfig.products.items is a ProductItem. The example below shows the full shape; the generated config ships an empty items: [] for you to fill. Set products.enabled: false to hide all one-time products without deleting the items.

// 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 entity
      maxStock: 10, // global cap
    },
  ],
},

ProductItem fields

KeyTypeRequiredDescription
idstringyesStable identifier used in code and storage.
namestringyesDisplay name (use an i18n key, as in the example).
descriptionstringnoDescription (i18n key, as in the example).
amountsRecord<string, number>yesPrice per currency code in major units (e.g. { USD: 99 }).
stripePriceIdstringnoStripe Price ID for this product.
polarProductIdstringnoPolar Product ID for this product.
creditsnumbernoCredits granted on purchase - turns it into a credit pack.
purchaseLimitnumbernoMax units a single entity may own. undefined = unlimited.
maxStocknumbernoGlobal stock cap across all buyers. undefined = unlimited.
restrictToPlansstring[]noOnly allow purchase for entities on these plan IDs.
trackedbooleannoRecord ownership in ownedProducts. Implicitly true when purchaseLimit or maxStock is set.
featuredbooleannoHighlight the item in product listings.

Provider refs are direct fields (stripePriceId / polarProductId), so one product can map to either provider without code changes.

A product is tracked only when tracked: true, purchaseLimit, or maxStock is set (isProductTracked). An untracked product - say a credit pack that only sets credits - grants credits on purchase but writes no ownedProducts row, so ownsProduct() returns false for it. Set tracked: true if you need to query ownership.

Ownership model

Tracked purchases are recorded in the ownedProducts table, keyed by billing entity - a user or an active organization, resolved per session by getBillingScope() (returns "user" unless config.tenancy.multiTenant and billingScope: "organization").

  • One row per (entityType, entityId, productId) holds the owned quantity.
  • purchaseLimit caps units per entity; maxStock caps units globally.
  • Webhooks keep ownedProducts in sync after checkout - see Background jobs.

Limits are checked at checkout and re-checked when the webhook fires (parallel checkout tabs can slip past the first guard). An over-limit purchase that reaches the webhook is not auto-reversed - it logs an error for manual refund. An over-maxStock purchase is processed anyway with a warning.

Querying ownership

Construct Billing from a session, then await the entitlement checks. All methods are async and read local state kept in sync by provider webhooks.

import { Billing } from "@repo/payments";

const billing = await Billing(session);
await billing.ownsProduct("addon_api");
MethodReturnsNotes
ownsProduct(id)Promise<boolean>true when owned quantity ≥ 1.
getProductQuantity(id)Promise<number>0 if none owned.
getOwnedProductsWithQuantities()Promise<OwnedProduct[]>{ productId, quantity } per tracked product.
getOwnedProductIds()Promise<string[]>IDs of tracked products owned.

Frequently asked questions

Are purchases owned by the user or the organization? Whichever billing scope is active. Billing(session) routes via getBillingScope() - to the active organization when org-scoped, otherwise the user - so seats and add-ons follow team billing.

How do I make a product grant credits? Set the credits field on a ProductItem (e.g. credits: 100) and that product becomes a credit pack - the credits land on the buyer's balance at checkout. See Credits.

How do I enforce a one-per-customer limit or run out of stock? Set purchaseLimit: 1 for a per-entity cap and maxStock for a global cap. Use restrictToPlans to gate purchase behind a subscription tier.

On this page