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
| Plan | id | type | Intervals | Credits | Trial |
|---|---|---|---|---|---|
| Free | free | free | lifetime at 0 | 5 / 30 days | none |
| Starter | starter | subscription | month | 10 / 30 days | 14 days |
| Pro | pro | subscription | month, year | 40 / 30 days | 14 days |
| Business | business | subscription | month, year, lifetime | 100 / 30 days | 14 days |
| Enterprise | enterprise | one_time | lifetime | 500 once | none |
- Tier order is array position - reorder only to change upgrade/downgrade logic.
defaultPlanships"free"and is assigned to new users;nullfor no default.defaultApiRateLimit({ maxRequests: 100 }) applies to any plan omittingapiRateLimit.trialDayssets the free trial (14 days on Starter, Pro and Business); addgrantCreditsOnTrialto 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).
| Field | Type | Notes |
|---|---|---|
id | string | Stable code identifier (e.g. "pro"); becomes user.plan |
type | "subscription" | "one_time" | "free" | Billing model |
name | string | i18n key - resolved at render |
description | string | i18n key |
features | string[] | i18n keys - bullet list |
limits | string[] | i18n keys - usage limits (optional) |
prices | PriceOption[] | One entry per interval |
featured | boolean | Highlight as recommended (only pro by default) |
credits | number | Granted per cycle - see Credits |
creditInterval | number | Days between credit re-grants (e.g. 30) |
apiRateLimit | ApiRateLimitConfig | { maxRequests, timeWindow? }; timeWindow defaults to 24h (86400000 ms) |
maxConcurrentRuns | number | Concurrent 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 |
trialDays | number | Free trial length |
grantCreditsOnTrial | boolean | Grant 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.
| Field | Type | Notes |
|---|---|---|
interval | "month" | "year" | "lifetime" | Billing cycle |
amounts | Record<string, number> | Major units keyed by currency (e.g. { USD: 29 }) |
anchorAmounts | Record<string, number> | Optional strike-through "was" prices |
stripePriceId | string | Provider price ref (Stripe) |
polarProductId | string | Provider price ref (Polar) |
featured | boolean | Highlight 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.
| Key | Type | Default | Description |
|---|---|---|---|
base | string | "USD" | Base currency code, and the currency all revenue is recorded in |
list | Currency[] | USD, EUR | Selectable currencies (symbol, code, place, space) |
countryMap | { default } & Record<string,string> | 27 EU codes to EUR | Maps ISO country code to currency code |
- Add a currency by adding a
Currencyobject tolist, then its code to everyamounts/anchorAmountsmap that should support it. mainCurrencyresolves thebasecurrency object fromlist;countryMap.defaultcovers unmapped visitors.- Currency selection and rendering are framework-specific - see Data fetching and Theming.
- Changing
baseafter 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 sold | What is recorded |
|---|---|
| Subscription (activation + renewal) | the plan's configured price |
| One-time product, lifetime plan, credit pack | the 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.
Billing & Payments
Sell subscriptions, one-time products, and credits through one provider-agnostic billing layer where every balance and plan belongs to a user.
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.