Menu

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.ts is 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

config/pricing.ts
{
  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)

config/pricing.ts
{
  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

config/pricing.ts
{
  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

FieldTypeRequiredDescription
idstringStable 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 providerProvider-side reference, see below
pricenumberDisplay price in major units (9.9 = $9.90). The charged currency comes from the provider
currencystringISO currency code, for display
copy{ en, zh, ja, ... }Per-locale card copy; en is mandatory and serves as the fallback
originalPricenumberStrikethrough price for promotions
promotionCodestringPromotion code auto-applied at this plan's checkout; overrides the site-wide campaign
popularbooleanHighlight this card on the pricing page
activebooleanfalse = hidden from the pricing page, while historical resolution keeps working

Subscription-only fields

FieldTypeRequiredDescription
kind'subscription'
interval'month' | 'year'
monthlyCreditsnumberMonthly 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
seatsnumberTeam plansSeat cap
trialDaysnumberTrial period in days

Credit-pack-only fields

FieldTypeRequiredDescription
kind'credit_pack'
creditsnumberCredits 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:

providerField to fillWhat goes in it
stripestripePriceIdStripe Price ID
creemcreemProductIdCreem Product ID
paypalpaypalPlanIdPayPal 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_KEY prefix (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 --write

The 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 row

Feature 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:

config/pricing.ts
// 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:

  1. Webhook fulfillment throws on every invoice → returns 5xx
  2. Stripe retries with exponential backoff for up to 3 days
  3. 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:

ExportPurpose
pricingPlansAll plans, including retired ones
activePricingPlansPlans with active !== false
subscriptionPlans / creditPackPlansFiltered by kind
personalSubscriptionPlans / teamSubscriptionPlansFiltered 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 / isStripePlanType guards

How the frontend renders it

The pricing page components live in components/pricing/:

  • PricingSection.tsx — Server Component with the id="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 grid
  • PricingCard.tsx — A single card. The CTA routes to PayPalCheckoutButton for PayPal credit packs and to CheckoutButton otherwise
  • CheckoutButton.tsx — Calls createCheckoutSession and 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

  1. Restart the dev server (the config is a module-level constant, so hot reload may not pick it up)
  2. The pricing page, checkout and webhooks all read the new config — nothing else to sync
  3. Before going live, make sure the live column of every price id is filled in