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 |
- 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, withpendingUpgradefor an upgrade awaiting payment, onGET /billing/subscription/pending- read from Stripe, signed-in sessions only, and called by the billing screen alone so the frequently-polledGET /billing/statusnever 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. 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). Omit it for a ONE-TIME grant - what the shipped free plan does, so its 5 credits land at sign-up and never renew |
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 |
automationMinutesPerMonth | number | Wall-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 |
maxMembers | number | Members 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) |
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). Wins over stripeLookupKey |
stripeLookupKey | string | Stripe lookup_key to sell by when stripePriceId is blank - see below |
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.
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 } },
],| Behavior | Detail |
|---|---|
| Resolution | prices.list({ lookup_keys, active: true }) maps each key to the account's active price, cached per process for 10 minutes. |
| Price check | A 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 keys | Each 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 resolver | Checkout, 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 key | Moving 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 history | Every 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 unreachable | Fails 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.
| 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.