定价配置 config/pricing.ts
提示
v4.0.0 起,定价不再存数据库,管理后台也不再有定价管理页面。
config/pricing.ts是定价的唯一事实来源。
为什么定价写在代码里
- 改价是低频但高危的操作,值得走一次代码审查,而不是在后台点两下就生效
- 钱的事实来源永远在支付渠道那边。这个文件只做一件事:把一个套餐 slug 映射到「渠道侧的价格对象 + 赠送积分 + 卡片文案」
- 卡片文案(名称、描述、功能列表)直接内联在套餐对象里,一个套餐就是一个对象,不再散落在 i18n 文件
一个套餐长什么样
订阅套餐
{
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 クレジット', '...'] },
},
}积分包(一次性购买)
{
id: 'pack-standard',
kind: 'credit_pack',
credits: 5000, // 一次性发放,进购买桶,永不过期
provider: 'stripe',
stripePriceId: { test: '...', live: '...' },
price: 49,
currency: 'USD',
active: true,
copy: { en: { ... }, zh: { ... }, ja: { ... } },
}团队套餐
{
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 同理
},
}字段参考
公共字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | ✅ | 稳定 slug。订单、订阅、积分流水永久引用它 |
provider | 'stripe' | 'creem' | 'paypal' | ✅ | 该套餐由谁处理结账 |
stripePriceId / creemProductId / paypalPlanId | { test, live } | 按 provider | 渠道侧引用,见下节 |
price | number | ✅ | 展示价,主币种单位(9.9 = $9.90)。实际扣费币种以渠道为准 |
currency | string | ✅ | 展示用 ISO 币种码 |
copy | { en, zh, ja, ... } | ✅ | 各语言卡片文案,en 必填并作为兜底 |
originalPrice | number | 划线原价,用于促销展示 | |
promotionCode | string | 该套餐结账时自动应用的促销码,优先级高于全站活动 | |
popular | boolean | 在定价页高亮这张卡 | |
active | boolean | false = 从定价页下架,但历史解析照常工作 |
订阅套餐专有
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
kind | 'subscription' | ✅ | |
interval | 'month' | 'year' | ✅ | |
monthlyCredits | number | ✅ | 每月积分额度。订阅桶每次发放重置为该值,不累积。年付套餐按月滴灌这个数 |
audience | 'personal' | 'team' | 省略即个人。'team' 表示计费主体是组织 | |
seats | number | 团队套餐 | 席位上限 |
trialDays | number | 试用天数 |
积分包专有
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
kind | 'credit_pack' | ✅ | |
credits | number | ✅ | 一次性发放的积分,进购买桶,永不过期 |
渠道侧引用怎么填
每个套餐用 provider 声明由谁收款,然后只填对应的那个字段:
| provider | 填哪个字段 | 填什么 |
|---|---|---|
stripe | stripePriceId | Stripe Price ID |
creem | creemProductId | Creem Product ID |
paypal | paypalPlanId | PayPal 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。
促销码
两个层级,套餐级优先于全站级:
// 全站活动
export const pricingCampaign: PricingCampaign = {
promotionCode: 'LAUNCH20',
}
// 单个套餐
{ id: 'pro-monthly', promotionCode: 'PRO30', ... }配置了促销码,结账时会自动应用(Stripe 与 Creem 支持,PayPal 不支持)。没有配置时,收银台会展示渠道自带的手工促销码输入框。
促销码本身在 Stripe 侧创建和管理,模板提供了优惠券控制台。注意测试环境和正式环境需要各建一份同名的对客码。
提示
如果配置的促销码在当前模式下不存在或已停用,结账不会失败,而是自动回落到手工输入框。过期的活动配置不会阻断收款。
上下架铁律
planId 永不复用,售出过的套餐条目永不物理删除。
原因是现存订阅的每一张续费/升级账单都要通过这个文件反查套餐(两条路径 —— metadata.planId 和渠道侧价格 ID —— 最终都落到这里)。删掉条目会导致:
- webhook 履约在每张账单上抛错 → 返回 5xx
- Stripe 以指数退避重试长达 3 天
- 每次重试都给管理员发一封发放失败告警邮件
下架请一律用 active: false。它只影响定价页的展示,历史解析照常工作。
查询辅助方法
config/pricing.ts 同时导出了一批派生数组和查询方法,运行时代码只读它们:
| 导出 | 用途 |
|---|---|
pricingPlans | 全部套餐(含已下架) |
activePricingPlans | active !== 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,其余走CheckoutButtonCheckoutButton.tsx—— 调用createCheckoutSession并跳转到渠道收银台,同时把各种拦截码映射到正确的去处(登录页、账单页、团队页,或一个 toast)
区块级 UI 文案(标题、标签页名称等)走 Pricing i18n 命名空间;套餐文案来自本文件。
改完定价之后
- 重启开发服务器(配置是模块级常量,热更新不一定生效)
- 定价页、结账、webhook 会同时读到新配置,无需其他同步动作
- 上线前确认
live列的价格 ID 已经填好