GenerateSaaS

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

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 user may own. undefined = unlimited.
maxStocknumbernoHard global cap on units sold across all buyers. undefined = unlimited.
restrictToPlansstring[]noOnly allow purchase for users 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, so one product maps to either provider with no code changes. Every amounts map must price config.currency.base - see Plans and pricing.

A product is tracked only when 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 owned quantity.
  • purchaseLimit caps units per user; maxStock caps units globally. Both are hard - a paid delivery past either one is refused, never granted.
  • Webhooks keep ownedProducts in 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.
  • maxStock is enforced against a productStock counter row and the live ownedProducts sum, whichever is higher. Editing ownedProducts by hand therefore cannot reopen a spent drop - raise maxStock instead.

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");
MethodReturnsNotes
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.

On this page