我们卖出了一个根本不能用的套餐
我们上线了一个 €4.90/月的套餐。年付选项会每月扣 €58.80,套餐不授予任何权限,worker 也无视时长上限。三个问题都是靠我们自己下单买出来的。
2026年10月4日
创建于 2026 年 10 月 4 日 · 更新于 2026 年 10 月 4 日
一个 €4.90/月的套餐在我们的定价页上线了。任何选择年付的人,都会被每月扣款 €58.80。
没有人被扣。
我们在第一个客户购买年付版之前就发现了它——不是靠读代码,而是靠我们自己用真实银行卡把它买了一遍。
这一点很关键。构建是绿的,TypeScript 没有抱怨,API 返回 200。这些都不能说明产品真的能用。
那天上午我们发现了三个缺陷。我们第一位付费的 Solo 客户,在这三个修复中最后一个上线二十七分钟后完成了订阅。时间往另一边偏半小时,付钱买下一个什么权限都没有的套餐的人就是他。
缺陷一:价格是对的,计费周期不对
我们新的入门套餐 Solo,年付是 €4.90/月,月付是 €8.90。€4.90 × 12 = €58.80,一年收一次。
年付选项背后的那个 Stripe 价格对象,创建时用的是「按月」计费周期。
也就是说,Stripe 准备每月扣 €58.80——是预期价格的十二倍。
定价页是对的,代码用的价格 ID 也是对的。这个缺陷活在代码仓库之外,在一个从控制台创建出来的 Stripe 对象里。
正是这一点让它极容易被漏掉。
Stripe 不允许就地修改已有价格的计费周期:你只能新建一个价格,然后替换 ID。所以我们在创建结账会话之前加了一道检查:
const expected = effectivePeriod === "annual" ? "year" : "month";
const price = await stripe.prices.retrieve(priceId);
if (price.recurring?.interval !== expected) {
throw new Error("PRICE_INTERVAL_MISMATCH");
}
每次结账一次 API 调用。
如果 Stripe 和定价页说的不一致,我们就拒绝卖。
那个有问题的 Stripe 对象仍然可以存在,它只是没法再悄无声息地通过结账了。
缺陷二:套餐在商业上存在,在功能上不存在
第二个缺陷朝相反方向失败:客户付了钱,却仍然被当成免费用户。
加一档套餐感觉像是商业动作。实际上,它触及产品里每一个会问这句话的地方:「这个账号被允许做什么?」
在我们的代码库里,这个问题是在 29 个文件里回答的。
这些判断是长年累月积累起来的,散布在导出、上传、处理队列、API 端点、计费和控制台里。大多是字符串比较,写的时候针对的是当时存在的那几档套餐。
Solo 是新的。
于是像是 pro、creator 还是 studio?这类判断,统统落到了默认分支。而默认分支是免费档。
这意味着一个付费的 Solo 账号,照样会拿到免费配额、免费上传限制、水印,以及免费档的其他各种限制。
这个默认值背后的直觉并不愚蠢。在安全领域,「失败即关闭」通常正是你想要的。
但在计费领域,它造就了另一种失效模式:客户已经付过钱了,而你最安全的兜底,恰好变成了扣住他买到的东西不给他。
修复的办法不是把 "solo" 加进 29 处条件判断。我们用一个地方取代了它们,套餐的行为只在这里描述:
export type Plan = "free" | "solo" | "pro" | "creator" | "studio";
export function isPaid(plan): boolean
export function hasDubbing(plan): boolean
export function hasScheduling(plan): boolean
export function videoQuota(plan, status): number
export function maxDurationMinutes(plan): number
export function maxUploadSize(plan): number
export function retentionHours(plan): number | null
上面的命名为了文章做了简化,但结构就是我们上线的那套。
这个文件开头现在写着一条规则:一个套餐只有写在这里,才算存在。
修复过程中有两次很值得记下的险情。
第一,我们差点把那一个 isPaid() 判断用在每一道门上,包括那些针对具体功能的门。那会让 Solo 客户拿到他们没付钱的东西,比如 AI 配音和发布排期。「付费了」和「有权使用这个功能」不是同一个问题。
第二,我们有一阵以为排期功能完全没有套餐校验,因为在那个路由里搜 "plan" 什么都搜不到。那道门在一个共享的辅助函数里,只隔着一次调用。
搜不到结果的 grep,不等于答案。
缺陷三:宣传了却从未执行的上限
定价页写着 Solo 支持最长 60 分钟的源视频。而负责下载和处理视频的 worker 只知道两个数:
- 免费账号 30 分钟
- 付费账号 90 分钟
Solo 是付费的。于是 Solo 拿到了 90。
这个缺陷朝另一个方向漏。没有人被多收钱,也没有人失去权限。我们只是白送了超出套餐应含量的算力。
对一个视频产品来说,这很要紧。源视频时长是与转写、渲染和带宽成本最直接相关的因素之一。
worker 现在带着和应用相同的上限:
def _max_minutes(plan: str | None) -> int:
"""Mirror of the plan limits used by the app.
What the pricing page announces and what the worker enforces
must be the same number.
A cap announced without being applied is margin walking out;
applied without being announced is a trap.
"""
只存在于定价页上的上限不是上限,只是页面上的一句话。
真正找出这些问题的是什么
不是类型检查器,不是测试套件,也不是代码评审。
找出它们的,是打开网站、假装自己从没见过它、点最便宜的那个选项、然后输入一张真卡。
计费不一致出现在 Stripe 自己的结账摘要里。缺失的权限出现在付费账号依旧表现得像免费账号的时候。时长问题出现在我们去问 worker「你实际会套用哪个上限」的时候。
我们一再重学同一个教训:构建通过并不证明产品可用。
每个缺陷都活在两个系统之间的缝隙里,而这两个系统单独看都在严格执行各自被告知的事:
- 定价页与 Stripe
- 套餐的名字与它实际授予的权限
- 应用与 worker
找出这一类缺陷的廉价办法,无聊得出奇:按付钱那个人的用法去用产品。
关于归因的后记
我们第一位 Solo 订阅者,就在同一个上午出现。
有大约一个小时,我们给自己讲了一个漂亮的故事:Solo 刚上线,有人自己找到了它,撞上免费额度,然后转化了。
事件日志讲的故事没那么整齐。
这个人是通过 ChatGPT 找到 Katto 的,注册、把免费套餐用得很狠、用尽配额、好几次打开结账页,然后没付钱就走了。
第二天早上,一封放弃结账的挽回邮件发了出去。几个小时后,他订阅了。
他选了 €8.90 的月付,而不是年付的 €4.90,为了避开十二个月的承诺,每月多付 82%。前一天他点过又放弃的创始优惠是 €6。
我们还以为是源视频 60 分钟的上限把他推向了 Solo。并不是。他反复撞上的是每月两条视频的配额。
于是两个假设同时死了。这次转化并不只是自然流量——挽回邮件很可能起了作用。而制造最强升级压力的那个限制,不是我们以为的那个。
这和上面三个缺陷是同一个形状。从外面看,很容易讲出一个干净的故事。然后我们去看了实际发生的事。
Katto 把长视频变成竖版短片。我们公布我们测量到的数字,包括那些让我们难看的数字。