GenerateSaaS

Plans & Pricing

Define subscription tiers, per-currency amounts, and billing intervals in pricingConfig, and control how revenue and refunds are recorded.

Plans live in pricingConfig - a standalone named export from packages/config/src/pricing.ts, not a key on config. It holds plans: PricePlan[], a defaultPlan id, and optional credits/products blocks, and renders the landing page's pricing section even when config.payment.enabled is false.

Shipped plans

PlanidtypeIntervalsCreditsTrial
Freefreefreelifetime at 05 / 30 daysnone
Starterstartersubscriptionmonth10 / 30 days14 days
Proprosubscriptionmonth, year40 / 30 days14 days
Businessbusinesssubscriptionmonth, year, lifetime100 / 30 days14 days
Enterpriseenterpriseone_timelifetime500 oncenone
  • Web upgrades and downgrades are ranked by price (per month, in the currency the subscription is billed in), never by array position: a switch of a running subscription applies at once (in the shipped UI after the customer confirms the amount in-app; the server also accepts a switch with no confirmed preview and charges it as of now - see Stripe plan changes) only when the new price is strictly higher per month (or equal at a longer interval), the interval is not shorter, and the prices Stripe actually bills agree (one licensed per-unit item at quantity 1, above zero, same currency). Everything else waits for the period end; if Stripe cannot be read the switch is refused (503) and can be retried. A subscription schedule created outside the app (Stripe dashboard) blocks in-app switches until you cancel it there. A switch that cannot be judged (another customer's subscription, a new price Stripe cannot bill in the subscription's currency) is refused. A new checkout is refused while the customer already holds a Stripe subscription that has not ended, so an account never runs two. App-store tiers still follow array position.
  • The billing screen names a pending change - "Switching to Pro monthly on Dec 1", or the date a cancellation ends the subscription (pendingPlan / pendingChangeAt, with pendingUpgrade for an upgrade awaiting payment, on GET /billing/subscription/pending - read from Stripe, signed-in sessions only, and called by the billing screen alone so the frequently-polled GET /billing/status never spends Stripe's rate limit). A scheduled downgrade or a pending cancellation can be undone from the billing screen (web and desktop) with Keep my current plan (POST /billing/subscription/keep-plan, signed-in sessions only, the caller's own subscription only). It releases only schedules the app created; one made in the Stripe dashboard is left for you to change.
  • defaultPlan ships "free" and is assigned to new users; null for no default.
  • defaultApiRateLimit ({ maxRequests: 100 }) applies to any plan omitting apiRateLimit.
  • trialDays sets the free trial (14 days on Starter, Pro and Business); add grantCreditsOnTrial to grant credits during it.
  • Credit fields apply only when credits are enabled - see Credits.

PricePlan fields

Each entry in plans is a PricePlan (packages/config/src/types/index.ts).

FieldTypeNotes
idstringStable code identifier (e.g. "pro"); becomes user.plan
type"subscription" | "one_time" | "free"Billing model
namestringi18n key - resolved at render
descriptionstringi18n key
featuresstring[]i18n keys - bullet list
limitsstring[]i18n keys - usage limits (optional)
pricesPriceOption[]One entry per interval
featuredbooleanHighlight as recommended (only pro by default)
creditsnumberGranted per cycle - see Credits
creditIntervalnumberDays between credit re-grants (e.g. 30). Omit it for a ONE-TIME grant - what the shipped free plan does, so its 5 credits land at sign-up and never renew
apiRateLimitApiRateLimitConfig{ maxRequests, timeWindow? }; timeWindow defaults to 24h (86400000 ms)
maxConcurrentRunsnumberConcurrent cloud AI runs allowed on this plan's balance - a streaming chat turn or an executing cloud automation. Read from the plan of whoever funds the workspace. Omit to inherit config.ai.runLimits.perPayer; omitting is not unlimited
automationMinutesPerMonthnumberWall-clock minutes of cloud automation runs (scheduled and Run now) the plan's payer gets per UTC calendar month, across every automation they fund. Omit for unlimited; shipped free 60, starter 600, pro 3000. See Automations
maxMembersnumberMembers one organization funded by this plan may hold, owner included. Read from the plan of whoever funds the workspace, and enforced when a member JOINS - an invitation to a full workspace is refused on acceptance. Omit for unlimited (the shipped default)
trialDaysnumberFree trial length
grantCreditsOnTrialbooleanGrant credits during the trial (default false)

name, description, features, and limits are i18n keys, not literal copy. Edit packages/i18n/translations/en/web.json - never hardcode text in pricing.ts. See i18n.

PriceOption fields

A plan's prices array carries one PriceOption per billing interval.

FieldTypeNotes
interval"month" | "year" | "lifetime"Billing cycle
amountsRecord<string, number>Major units keyed by currency (e.g. { USD: 29 })
anchorAmountsRecord<string, number>Optional strike-through "was" prices
stripePriceIdstringProvider price ref (Stripe). Wins over stripeLookupKey
stripeLookupKeystringStripe lookup_key to sell by when stripePriceId is blank - see below
polarProductIdstringProvider price ref (Polar)
featuredbooleanHighlight this option (e.g. "Best Value")

Every currency code used in amounts must exist in config.currency.list, or it can be neither selected nor displayed.

A plan, annotated

The pro plan shows the full shape - two intervals, anchor pricing, one provider ref per price:

// packages/config/src/pricing.ts
{
  id: "pro",
  type: "subscription",
  name: "pricing.plans.plan_3.name",         // i18n key
  description: "pricing.plans.plan_3.description",
  featured: true,                            // recommended badge
  features: ["pricing.plans.plan_3.features.item_1", /* … */],
  prices: [
    {
      stripePriceId: "",                     // your provider's ref - fill from the dashboard
      interval: "month",
      amounts: { USD: 29 },
      anchorAmounts: { USD: 39 },            // strike-through
      featured: true,
    },
    { stripePriceId: "", interval: "year", amounts: { USD: 290 }, anchorAmounts: { USD: 349 } },
  ],
  credits: 50,                               // granted per cycle
  creditInterval: 30,                        // re-grant every 30 days
  apiRateLimit: { maxRequests: 5000 },
}

Plans ship with empty stripePriceId/polarProductId strings, and only the provider you picked at init is present. Create the matching prices in your provider dashboard and paste the ids back here, or checkout fails.

Selling by Stripe lookup key

Leave stripePriceId blank and set stripeLookupKey to sell each price by its Stripe lookup_key, so test and live mode each use their own price with no id committed.

prices: [
  { stripePriceId: "", stripeLookupKey: "pro_monthly", interval: "month", amounts: { USD: 29 } },
  { stripePriceId: "", stripeLookupKey: "pro_yearly", interval: "year", amounts: { USD: 290 } },
],
BehaviorDetail
Resolutionprices.list({ lookup_keys, active: true }) maps each key to the account's active price, cached per process for 10 minutes.
Price checkA key's price is sold only if it charges exactly what the pricing page shows: active, per_unit, the configured amount in its currency, the plan's interval with interval_count 1 and licensed usage (or one-time for lifetime and products), no custom amount or quantity transform. Anything else stays unmapped and is logged. The billing plugin's own lookup-key read at subscription checkout gets only the verified price.
Unique keysEach key may name one plan price or product. A duplicate fails the project's own test suite and refuses to resolve at run time.
One resolverCheckout, plan-switch judgment and preview, scheduled and pending plans, subscription positions, paid upgrade invoices, lifetime and product webhooks all map prices to plans through getPlanPriceByStripePriceId / getPriceIdForProvider.
Moving a keyMoving a lookup key to a new price (transfer_lookup_key) is picked up on the next price.* / product.* webhook in the receiving process, else within 10 minutes. Enable price.* and product.* events on your endpoint. New checkouts sell the new price; subscribers billed at a price the key named before keep their plan.
Price historyEvery verified price is recorded in stripe_price_history, which is what still maps an old price to its plan after the key moves. A price is mapped back only to the plan and interval it was verified for, never sold again, and one never verified maps to nothing.
Stripe unreachableFails closed: checkout answers 503, a plan switch is refused as unreadable, and a webhook answers non-2xx so Stripe retries. Nothing is mapped from a guess.

Lookup keys are unique per Stripe account, so a plan's monthly and yearly prices need two keys. A price is recorded only once a deployment with price history has verified it, so a subscription billed at a price whose key moved away before that maps to no plan - keep the key on that price until it has been resolved once, or set stripePriceId.

Currency

Display currency comes from config.currency in packages/config/src/index.ts.

KeyTypeDefaultDescription
basestring"USD"Base currency code, and the currency all revenue is recorded in
listCurrency[]USD, EURSelectable currencies (symbol, code, place, space)
countryMap{ default } & Record<string,string>27 EU codes to EURMaps ISO country code to currency code
  • Add a currency by adding a Currency object to list, then its code to every amounts/anchorAmounts map that should support it.
  • mainCurrency resolves the base currency object from list; countryMap.default covers unmapped visitors.
  • Currency selection and rendering are framework-specific - see Data fetching and Theming.
  • Changing base after launch means adding that code to every existing price. Historical revenue rows keep the currency they were recorded in.

Every price needs the base currency

base is the only mandatory currency. Every amounts map - plans, products, any anchorAmounts - must price config.currency.base. The rest of list is optional: a visitor whose currency is not priced sees the base price.

A price missing the base currency fails your test suite - config-invariants names the offending plan or product - and at runtime contributes nothing to revenue analytics and logs a config error. Revenue is recorded in the base currency or not at all, never converted.

Revenue and refunds

Recorded revenue is net of sales tax on both providers - collected tax is owed to a tax authority, never income.

What was soldWhat is recorded
Subscription (activation + renewal)the plan's configured price
One-time product, lifetime plan, credit packthe amount charged, minus sales tax

Refunds reverse what was recorded, not what the provider reports refunding. A refund takes its share of the revenue the purchase booked - 25% refunded reverses 25% - so a full refund nets to exactly zero and partial refunds reverse the whole and never more, on either provider's tax basis.

Three reversal limits.

  • Subscription refunds reverse the refunded cycle's own credit grant - the allotment the ledger records handing over, never the plan's current size. A subscriber whose plan changed mid-subscription has cycles of different sizes and the charge cannot say which it paid for, so the largest that charge could have paid for is taken. The cycle is reversed in full, like a refunded product: a customer who already spent it is left in credit debt. The plan itself is not cleared - cancel the subscription if access should end.
  • Stripe refunds carry no invoice id, so a refund that cannot be tied to its own payment matches against that customer's purchase history for the product. Agreeing rows match exactly; when they disagree (the same product bought either side of a re-price) the reversal takes the largest grant and the largest booking the charge could have produced, and logs both as warnings - an attribution that cannot be exact costs the customer, never you. Polar orders carry an id on both sides and always match exactly.
  • A refund with no purchase record falls back to the provider's refund amount, which may carry tax the purchase never booked, and logs a warning. With no record and no refund amount, nothing is reversed and the event is logged for manual reconciliation.

On this page