Menu

优惠券控制台

提示

优惠券控制台是 v4.0.0 新增的,入口在 /dashboard/admin/coupons。它只对 provider: 'stripe' 的套餐生效。

设计前提:Stripe 是唯一存储

本地不建表、不做迁移、不存任何优惠券数据。 页面每次交互都直接读写 Stripe。

这么做的理由很简单:优惠券的兑换次数、是否过期、是否被停用,事实来源永远在 Stripe 那边。本地存一份只会带来同步问题,而收益是零 —— 优惠券的读写频率低到不需要缓存。

代价是列表功能受 Stripe API 能力限制,见下文「列表的扫描上限」。

数据模型:一个优惠券是两个对象

Stripe 把优惠券拆成两层,模板沿用这个语义:

Coupon(折扣本身)
├── 折扣:percent_off 或 amount_off(二选一)
├── 适用范围:applies_to.products(空 = 全部产品)
└── 计费时长:duration = once | repeating | forever
                repeating 时还要给 duration_in_months
 
Promotion Code(对客的码)
├── code:顾客在收银台输入的字符串,如 LAUNCH20
├── max_redemptions:总兑换次数上限
├── expires_at:过期时间
└── restrictions:first_time_transaction(仅新客)/ minimum_amount(最低订单额)

创建时两个对象一次性建好。如果第二步(建 promotion code)失败 —— 最常见的原因是码重复 —— 代码会把刚建好的 coupon 删掉,不留孤儿对象。

最重要的约束:码创建后不可修改

Stripe 的 promotion code 一旦创建,除了启用/停用开关,什么都改不了。历史发票会引用它,所以 Stripe 也永远不会真正删除它。

这条约束塑造了整个 UI:

想做的事实际能做的
改折扣力度❌ 只能停用旧码,复制一份改完再建新码
改适用套餐❌ 同上
改过期时间❌ 同上
改兑换次数上限❌ 同上
停止发放✅ 关掉启用开关
删除❌ Stripe 不支持,停用就是「删除」

所以列表行的操作只有两个:启用开关复制一份。「复制」会把当前码的所有参数预填进创建表单,改完提交就是一个新码。

coupon-list

状态

操作者视角的四种状态:

状态含义能否重新启用
active正常可用
disabled被手工停用✅ 可以重新打开
expired已过 expires_at❌ 终态
exhausted兑换次数已用尽❌ 终态

expiredexhausted 之所以单列而不是合并成一个「inactive」,正是因为 Stripe 拒绝重新启用它们 —— 行内的开关直接读状态来决定自己是否禁用。

创建一个优惠券

coupon-create

表单字段:

字段说明
留空则由 Stripe 生成。手填需匹配 ^[A-Z0-9_-]{3,30}$,会自动转大写
名称优惠券展示名,出现在 Stripe 后台和发票上,最长 40 字符
折扣类型百分比(0 < x ≤ 100)或固定金额(整数分)。二选一,互斥
适用套餐多选,最多 20 个。留空 = 全部产品
计费时长once(仅首期)/ repeating(前 N 个月,1–36)/ forever(每期都打折)
兑换次数上限留空 = 不限
过期时间提供「永不 / 7 天 / 30 天 / 90 天」快捷预设,也可自定义。必须是将来时间
仅限新客限定从未付过款的客户
最低订单额整数分,留空 = 无门槛

两个交互设计值得一提:

实时查重。 输入码时会实时调 checkPromotionCodeAvailability 问 Stripe 这个码占没占用,在提交前就告诉你,而不是把重复变成一次失败的创建。

效果摘要句。 对话框底部会用一句自然语言说清楚「顾客最终能得到什么」,比如「前 3 个月每期立减 $10,仅限 Pro 月付,仅新客可用」。折扣、范围、时长三者组合起来容易算错,这句话是防呆。

适用范围怎么落到 Stripe

表单里选的是套餐 slug,提交时会翻译成 Stripe 的 product id:

planId(config/pricing.ts)
    ↓ getPlanById + isStripePlan 校验
Stripe Price ID
    ↓ 反查
Stripe Product ID
    ↓ 写入
coupon.applies_to.products

如果选中的套餐不是 Stripe 渠道的(provider 不是 stripe),会直接拒绝并告诉你是哪个套餐。

注意测试/正式模式

product id 在 test 和 live 两个模式下是不同的。在测试模式建的优惠券只对测试模式的产品生效,上线前需要在正式模式重建一份同名的码。

自动应用

config/pricing.ts 里配置了促销码,结账时会自动应用,顾客不需要手动输入:

config/pricing.ts
// 全站活动
export const pricingCampaign: PricingCampaign = {
  promotionCode: 'LAUNCH20',
}
 
// 或单个套餐(优先级更高)
{ id: 'pro-monthly', promotionCode: 'PRO30', ... }

列表里被自动应用的码会带一个标记。这个标记很重要:这类码对每个访客都是生效的,停用它等于悄无声息地取消了全站折扣,顾客不会看到任何错误提示,只会发现价格变了。所以停用前先确认 config/pricing.ts 里没在引用它。

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

详见定价配置的促销码一节。

列表的扫描上限

Stripe 的 promotion code 列表接口只有游标分页,没有文本搜索。想给运营提供「按码搜索 + 按状态筛 + 按套餐筛」,只能一次把全量拉下来在内存里过滤。

所以有一个硬上限:

const MAX_SCANNED_CODES = 500

一次最多扫 500 个码(每页 100,自动翻页)。返回结果里带两个字段:

字段用途
scanned过滤前从 Stripe 拉到的数量,用作「0 / 42」里的分母
truncated是否撞到了 500 上限,撞到说明更早的码被丢掉了

truncated 为真时,页面会显示扫描上限警告。Stripe 返回的是最新优先的顺序,这也正是运营建完码之后期望看到的顺序,所以代码保留了这个顺序而没有重排。

如果你的码数量长期超过 500,说明该考虑换个策略了 —— 比如给码加统一前缀后在 Stripe 后台管理,或者定期清理历史码。

服务端接口

actions/coupons/admin.ts,四个 action,全部带管理员校验:

Action作用
getAdminPromotionCodes({ pageIndex, pageSize, search?, status?, appliesTo? })列表:一次扫描 + 内存过滤分页
checkPromotionCodeAvailability(code)提交前查重
createAdminPromotionCode(input)建 coupon + promotion code,promo 失败则回滚 coupon
setPromotionCodeActive({ id, active })Stripe 唯一允许的修改操作

Stripe 未配置时所有 action 直接返回错误提示,页面也会据此展示不可用状态。

常见问题

Q: Creem 和 PayPal 的优惠券怎么办?

这个控制台只管 Stripe。Creem 走它自己的 discountCode(在 config/pricing.ts 配置促销码后,结账时会作为 discountCode 传给 Creem);PayPal 不支持自动应用促销码。

Stripe 的促销码只能限定到 Stripe product,这是它只覆盖 Stripe 的根本原因。

Q: 为什么不能改优惠券?

这是 Stripe 的约束,不是模板的选择。promotion code 被历史发票引用,允许修改会让历史账单的口径变得不可信。

Q: 停用的码还能被使用吗?

不能。但已经用过它的订阅,如果 duration 是 repeatingforever,折扣会继续按原条款生效到期 —— 停用只阻止新的兑换。

Q: 怎么看一个码被用了多少次?

列表里有兑换进度(已兑换 / 上限)。更详细的数据点击行内的 Stripe 后台深链,直达对应模式的 Stripe 页面。