Menu

定价配置 config/pricing.ts

提示

v4.0.0 起,定价不再存数据库,管理后台也不再有定价管理页面。config/pricing.ts 是定价的唯一事实来源。

为什么定价写在代码里

  • 改价是低频但高危的操作,值得走一次代码审查,而不是在后台点两下就生效
  • 钱的事实来源永远在支付渠道那边。这个文件只做一件事:把一个套餐 slug 映射到「渠道侧的价格对象 + 赠送积分 + 卡片文案」
  • 卡片文案(名称、描述、功能列表)直接内联在套餐对象里,一个套餐就是一个对象,不再散落在 i18n 文件

一个套餐长什么样

订阅套餐

config/pricing.ts
{
  id: 'pro-monthly',            // 稳定 slug,订单和积分流水永久引用它
  kind: 'subscription',
  interval: 'month',            // 'month' | 'year'
  monthlyCredits: 2000,         // 每月发放的积分(订阅桶每次发放时重置为该值)
  provider: 'stripe',           // 由谁收款
  stripePriceId: {              // 渠道侧引用,区分测试与正式
    test: 'price_xxx_test',
    live: 'price_xxx_live',
  },
  price: 29.9,                  // 展示价(主币种单位,不是分)
  currency: 'USD',
  popular: true,                // 在定价页高亮这张卡
  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 クレジット', '...'] },
  },
}

积分包(一次性购买)

config/pricing.ts
{
  id: 'pack-standard',
  kind: 'credit_pack',
  credits: 5000,                // 一次性发放,进购买桶,永不过期
  provider: 'stripe',
  stripePriceId: { test: '...', live: '...' },
  price: 49,
  currency: 'USD',
  active: true,
  copy: { en: { ... }, zh: { ... }, ja: { ... } },
}

团队套餐

config/pricing.ts
{
  id: 'team-monthly',
  kind: 'subscription',
  interval: 'month',
  audience: 'team',             // 标记为团队套餐:计费主体是组织
  seats: 5,                     // 席位上限,邀请与加入时强制校验
  monthlyCredits: 10000,        // 发放进组织的共享积分池
  provider: 'stripe',
  stripePriceId: { test: '...', live: '...' },
  price: 149.5,
  currency: 'USD',
  active: true,
  copy: {
    zh: {
      name: '团队版',
      description: '适合协同创作的团队 —— 共享积分池,5 个席位。',
      features: [
        // 对象形式 + highlight 会整行高亮,留给团队专属权益
        { text: '每月 **10,000 积分**,团队共享积分池', highlight: true },
        { text: '含 **5 个席位**,邀请整个团队', highlight: true },
        '每位成员均享专业版全部功能',   // 普通字符串渲染为弱化样式
      ],
    },
    // en / ja 同理
  },
}

字段参考

公共字段

字段类型必填说明
idstring✅稳定 slug。订单、订阅、积分流水永久引用它
provider'stripe' | 'creem' | 'paypal'✅该套餐由谁处理结账
stripePriceId / creemProductId / paypalPlanId{ test, live }按 provider渠道侧引用,见下节
pricenumber✅展示价,主币种单位(9.9 = $9.90)。实际扣费币种以渠道为准
currencystring✅展示用 ISO 币种码
copy{ en, zh, ja, ... }✅各语言卡片文案,en 必填并作为兜底
originalPricenumber划线原价,用于促销展示
promotionCodestring该套餐结账时自动应用的促销码,优先级高于全站活动
popularboolean在定价页高亮这张卡
activebooleanfalse = 从定价页下架,但历史解析照常工作

订阅套餐专有

字段类型必填说明
kind'subscription'✅
interval'month' | 'year'✅
monthlyCreditsnumber✅每月积分额度。订阅桶每次发放重置为该值,不累积。年付套餐按月滴灌这个数
audience'personal' | 'team'省略即个人。'team' 表示计费主体是组织
seatsnumber团队套餐席位上限
trialDaysnumber试用天数

积分包专有

字段类型必填说明
kind'credit_pack'✅
creditsnumber✅一次性发放的积分,进购买桶,永不过期

渠道侧引用怎么填

每个套餐用 provider 声明由谁收款,然后只填对应的那个字段:

provider填哪个字段填什么
stripestripePriceIdStripe Price ID
creemcreemProductIdCreem Product ID
paypalpaypalPlanIdPayPal Billing Plan ID,仅订阅需要。PayPal 积分包按 price 动态创建订单,不需要 plan id

每个字段都是 { test, live } 两套值。用哪一套由环境决定:

  • Stripe 看 STRIPE_SECRET_KEY 的前缀(sk_test / sk_live)
  • PayPal 看 NEXT_PUBLIC_PAYPAL_ENVIRONMENT(sandbox / live)

所以同一份配置文件在测试环境和生产环境都能直接跑,不需要改代码。

用脚本批量创建 Stripe 价格

不想手工在 Stripe 后台一个个建产品的话:

# 试运行,只打印将要创建的内容
pnpm stripe:bootstrap
 
# 确认后写入:创建 Stripe 产品和价格,并回填 config/pricing.ts
pnpm stripe:bootstrap --write

脚本是幂等的:产品按 metadata.planId 复用,价格按金额 + 币种 + 周期匹配复用,已经回填过的条目自动跳过。写入哪一列由 STRIPE_SECRET_KEY 的前缀决定。

多语言文案

copy 是一个 { 语言: PlanCopy } 的对象,en 必填并作为未知语言的兜底:

interface PlanCopy {
  name: string
  description: string
  features: PlanFeature[]
}
 
type PlanFeature =
  | string                                  // 普通行,弱化样式
  | { text: string; highlight?: boolean }   // highlight: true 整行强调

功能行支持 **加粗** 片段。highlight: true 会让整行凸显,约定只留给团队专属权益(席位、共享池、成员用量统计),这样它们在共同的基础功能里能跳出来。

读取文案用 getPlanCopy(plan, locale),未知语言自动落回 en。

促销码

两个层级,套餐级优先于全站级:

config/pricing.ts
// 全站活动
export const pricingCampaign: PricingCampaign = {
  promotionCode: 'LAUNCH20',
}
 
// 单个套餐
{ id: 'pro-monthly', promotionCode: 'PRO30', ... }

配置了促销码,结账时会自动应用(Stripe 与 Creem 支持,PayPal 不支持)。没有配置时,收银台会展示渠道自带的手工促销码输入框。

促销码本身在 Stripe 侧创建和管理,模板提供了优惠券控制台。注意测试环境和正式环境需要各建一份同名的对客码。

提示

如果配置的促销码在当前模式下不存在或已停用,结账不会失败,而是自动回落到手工输入框。过期的活动配置不会阻断收款。

上下架铁律

planId 永不复用,售出过的套餐条目永不物理删除。

原因是现存订阅的每一张续费/升级账单都要通过这个文件反查套餐(两条路径 —— metadata.planId 和渠道侧价格 ID —— 最终都落到这里)。删掉条目会导致:

  1. webhook 履约在每张账单上抛错 → 返回 5xx
  2. Stripe 以指数退避重试长达 3 天
  3. 每次重试都给管理员发一封发放失败告警邮件

下架请一律用 active: false。它只影响定价页的展示,历史解析照常工作。

查询辅助方法

config/pricing.ts 同时导出了一批派生数组和查询方法,运行时代码只读它们:

导出用途
pricingPlans全部套餐(含已下架)
activePricingPlansactive !== false 的套餐
subscriptionPlans / creditPackPlans按类型筛选
personalSubscriptionPlans / teamSubscriptionPlans按计费主体筛选
getPlanById(planId)按 slug 查套餐
findPlanByProviderPriceId(provider, id)反查:渠道侧 ID → 套餐(webhook 用,test/live 两列都匹配)
getProviderPriceRef(plan)取该套餐自身渠道的引用
getPlanCredits(plan)订阅返回月度额度,积分包返回总额
getPlanSeats(planId)团队席位上限;非团队套餐或已失效 slug 返回 1
getPlanCopy(plan, locale) / getPlanName(planId, locale)取本地化文案 / 名称
getAutoPromotionCode(plan)该套餐应自动应用的促销码
isSubscriptionPlan / isTeamPlan / isStripePlan类型守卫

前端如何渲染

定价页组件在 components/pricing/:

  • PricingSection.tsx —— 服务端组件,id="pricing" 锚点。三个标签页(月付 / 年付 / 积分包),该周期下存在团队套餐时,会在个人套餐网格下方追加团队子区块
  • PricingCard.tsx —— 单张卡片。PayPal 积分包的 CTA 路由到 PayPalCheckoutButton,其余走 CheckoutButton
  • CheckoutButton.tsx —— 调用 createCheckoutSession 并跳转到渠道收银台,同时把各种拦截码映射到正确的去处(登录页、账单页、团队页,或一个 toast)

区块级 UI 文案(标题、标签页名称等)走 Pricing i18n 命名空间;套餐文案来自本文件。

改完定价之后

  1. 重启开发服务器(配置是模块级常量,热更新不一定生效)
  2. 定价页、结账、webhook 会同时读到新配置,无需其他同步动作
  3. 上线前确认 live 列的价格 ID 已经填好