Pricing Configuration (config/pricing.ts)
Good to know
Since v4.0.0, pricing is no longer stored in the database and the admin dashboard no longer has a pricing management page.
config/pricing.tsis the single source of truth for pricing.
Why pricing lives in code
- A price change is rare but high-stakes. It deserves a code review, not two clicks in an admin panel
- The payment provider is always the source of truth for money. This file does exactly one job: map a plan slug to "the provider-side price object + the credits it grants + its card copy"
- Card copy (name, description, feature list) is inlined on the plan object itself. One plan is one object, no longer scattered across i18n files
What a plan looks like
Subscription plan
{
id: 'pro-monthly', // Stable slug; orders and the credit ledger reference it forever
kind: 'subscription',
interval: 'month', // 'month' | 'year'
monthlyCredits: 2000, // Credits granted per month (the subscription bucket RESETS to this)
provider: 'stripe', // Who collects the money
stripePriceId: { // Provider-side reference, one per environment
test: 'price_xxx_test',
live: 'price_xxx_live',
},
price: 29.9, // Display price in major units, not cents
currency: 'USD',
popular: true, // Highlight this card on the pricing page
active: true,
copy: {
en: { name: 'Pro', description: '...', features: ['2,000 credits every month', '...'] },
zh: { name: '专业版', description: '...', features: ['每月 2,000 积分', '...'] },
ja: { name: 'プロ', description: '...', features: ['毎月 2,000 クレジット', '...'] },
},
}Credit pack (one-time purchase)
{
id: 'pack-standard',
kind: 'credit_pack',
credits: 5000, // Granted once into the purchased bucket; never expires
provider: 'stripe',
stripePriceId: { test: '...', live: '...' },
price: 49,
currency: 'USD',
active: true,
copy: { en: { ... }, zh: { ... }, ja: { ... } },
}Team plan
{
id: 'team-monthly',
kind: 'subscription',
interval: 'month',
audience: 'team', // Marks a team plan: the billing subject is an organization
seats: 5, // Seat cap, enforced at invite and join
monthlyCredits: 10000, // Granted into the organization's shared pool
provider: 'stripe',
stripePriceId: { test: '...', live: '...' },
price: 149.5,
currency: 'USD',
active: true,
copy: {
en: {
name: 'Team',
description: 'For teams that create together — one shared pool, five seats.',
features: [
// The object form with highlight elevates the whole row — team-only perks
{ text: '**10,000 credits** every month in a shared team pool', highlight: true },
{ text: '**5 seats** included — invite your whole team', highlight: true },
'Everything in Pro, for every member', // A plain string renders muted
],
},
// zh / ja follow the same shape
},
}Field reference
Shared fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | ✅ | Stable slug. Orders, subscriptions and the credit ledger reference it forever |
provider | 'stripe' | 'creem' | 'paypal' | ✅ | Which provider handles this plan's checkout |
stripePriceId / creemProductId / paypalPlanId | { test, live } | Per provider | Provider-side reference, see below |
price | number | ✅ | Display price in major units (9.9 = $9.90). The charged currency comes from the provider |
currency | string | ✅ | ISO currency code, for display |
copy | { en, zh, ja, ... } | ✅ | Per-locale card copy; en is mandatory and serves as the fallback |
originalPrice | number | Strikethrough price for promotions | |
promotionCode | string | Promotion code auto-applied at this plan's checkout; overrides the site-wide campaign | |
popular | boolean | Highlight this card on the pricing page | |
active | boolean | false = hidden from the pricing page, while historical resolution keeps working |
Subscription-only fields
| Field | Type | Required | Description |
|---|---|---|---|
kind | 'subscription' | ✅ | |
interval | 'month' | 'year' | ✅ | |
monthlyCredits | number | ✅ | Monthly credit allowance. The subscription bucket resets to this on every grant — no rollover. Yearly plans drip this amount monthly |
audience | 'personal' | 'team' | Omitted means personal. 'team' makes an organization the billing subject | |
seats | number | Team plans | Seat cap |
trialDays | number | Trial period in days |
Credit-pack-only fields
| Field | Type | Required | Description |
|---|---|---|---|
kind | 'credit_pack' | ✅ | |
credits | number | ✅ | Credits granted once into the purchased bucket; they never expire |
Filling in the provider reference
Each plan declares who collects the money with provider, then fills in only the matching field:
| provider | Field to fill | What goes in it |
|---|---|---|
stripe | stripePriceId | Stripe Price ID |
creem | creemProductId | Creem Product ID |
paypal | paypalPlanId | PayPal Billing Plan ID, subscriptions only. A PayPal credit pack creates its order dynamically from price and needs no plan id |
Every such field holds two values, { test, live }. Which one is used follows the environment:
- Stripe follows the
STRIPE_SECRET_KEYprefix (sk_test/sk_live) - PayPal follows
NEXT_PUBLIC_PAYPAL_ENVIRONMENT(sandbox/live)
So the same config file runs unchanged in both test and production.
Creating the Stripe prices with a script
If you would rather not create each product by hand in the Stripe dashboard:
# Dry run — only prints what would be created
pnpm stripe:bootstrap
# Apply: create the Stripe products and prices, and backfill config/pricing.ts
pnpm stripe:bootstrap --writeThe script is idempotent: products are reused by metadata.planId, prices are matched and reused by amount + currency + interval, and entries already backfilled are skipped. The column it writes follows the STRIPE_SECRET_KEY prefix.
Localized copy
copy is a { locale: PlanCopy } object; en is mandatory and backs every unknown locale:
interface PlanCopy {
name: string
description: string
features: PlanFeature[]
}
type PlanFeature =
| string // Plain row, rendered muted
| { text: string; highlight?: boolean } // highlight: true elevates the whole rowFeature rows support **bold** segments. highlight: true is reserved by convention for team-exclusive perks (seats, the shared pool, per-member stats) so they pop against the shared baseline.
Read copy with getPlanCopy(plan, locale), which falls back to en for unknown locales.
Promotion codes
Two levels; the plan level beats the site level:
// Site-wide campaign
export const pricingCampaign: PricingCampaign = {
promotionCode: 'LAUNCH20',
}
// A single plan
{ id: 'pro-monthly', promotionCode: 'PRO30', ... }With a code configured, checkout applies it automatically (supported by Stripe and Creem; not by PayPal). Without one, the checkout page shows the provider's own manual promo-code input instead.
The codes themselves are created and managed on the Stripe side, and the boilerplate ships a coupon console. Note that test and live mode each need their own copy of the same customer-facing code.
Good to know
If the configured code does not exist or is inactive in the current mode, checkout does not fail — it falls back to the manual input. A stale campaign config never blocks a sale.
The iron rule about retiring a plan
A planId is never reused, and a plan entry that has ever been sold is never physically deleted.
Every renewal and upgrade invoice of an existing subscription resolves its plan through this file (both paths — metadata.planId and the provider-side price id — land here). Deleting an entry means:
- Webhook fulfillment throws on every invoice → returns 5xx
- Stripe retries with exponential backoff for up to 3 days
- Every attempt mails the admin a grant-failure alert
Always retire with active: false. It only affects the pricing page; historical resolution keeps working.
Lookup helpers
config/pricing.ts also exports derived arrays and lookup functions — runtime code reads only these:
| Export | Purpose |
|---|---|
pricingPlans | All plans, including retired ones |
activePricingPlans | Plans with active !== false |
subscriptionPlans / creditPackPlans | Filtered by kind |
personalSubscriptionPlans / teamSubscriptionPlans | Filtered by billing subject |
getPlanById(planId) | Look up a plan by slug |
findPlanByProviderPriceId(provider, id) | Reverse lookup: provider-side id → plan (used by webhooks; matches both the test and live values) |
getProviderPriceRef(plan) | The reference for the plan's own provider |
getPlanCredits(plan) | Monthly allowance for subscriptions, total for packs |
getPlanSeats(planId) | Team seat cap; non-team plans and retired slugs collapse to 1 |
getPlanCopy(plan, locale) / getPlanName(planId, locale) | Localized copy / name |
getAutoPromotionCode(plan) | The promotion code to auto-apply for this plan |
isSubscriptionPlan / isTeamPlan / isStripePlan | Type guards |
How the frontend renders it
The pricing page components live in components/pricing/:
PricingSection.tsx— Server Component with theid="pricing"anchor. Three tabs (monthly / yearly / credit packs); when a team plan exists for that interval, a team sub-section is appended under the personal gridPricingCard.tsx— A single card. The CTA routes toPayPalCheckoutButtonfor PayPal credit packs and toCheckoutButtonotherwiseCheckoutButton.tsx— CallscreateCheckoutSessionand redirects to the provider checkout, mapping each guard code to the right destination (login page, billing page, team page, or a toast)
Section-level UI text (headings, tab labels) comes from the Pricing i18n namespace; plan copy comes from this file.
After changing pricing
- Restart the dev server (the config is a module-level constant, so hot reload may not pick it up)
- The pricing page, checkout and webhooks all read the new config — nothing else to sync
- Before going live, make sure the
livecolumn of every price id is filled in