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
  • Tier order is array position - reorder only to change upgrade/downgrade logic.
  • 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)
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
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)
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.

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