以下情境为教学假设,人物与数字不代表经核实的真实案例。
合同期限看起来只是日历上的两个日期,真正影响现金的是三个时点:合同什么时候开始、什么时候自动续、客户最晚什么时候可以说不续。前两个时点决定收入能确认多久,第三个时点决定你在多长时间里知道收入会不会断。
自动续约把「客户要不要留下」变成了「客户有没有记得通知」。对卖方,这是一条默认保留收入的条款;对买方,这是一条默认锁定成本的条款。同一句话,在两个方向上产生完全不同的财务后果。LedgerLite 的年付客户占 30%,月流失率 1.5%;这两个数字决定了通知窗口值多少钱,也决定了错过窗口后多留一年的价值。
这一节只算三笔账:30/60/90 天通知窗口分别锁定多少收入、错过窗口后多留一年值多少、以及 Nord 的 70% 续约率如何改变同一组数字。它不写合同模板,也不判断条款是否可执行;它只把期限翻译成收入确定性。
通知窗口锁定多少收入
先确定基数。LedgerLite 的年收入是 $240,240,年付客户占 30%,所以年付 cohort 的 ARR 是:
年付 cohort ARR = $240,240 × 30% = $72,072
按 260 家客户算,年付客户约 78 家,平均每家年费 $924($77 × 12)。这里的 $924 是混合 ARPA 的年化值,不再单独拆分 15% 年付折扣;拆分会让 cohort 基数与总 ARR 对不上。78 × $924 = $72,072,和上面的乘法一致。月付客户约 182 家,年化收入 $168,168;它们的退出节奏由月付条款决定,不进入这一节的年付 cohort。
通知窗口锁定的收入,等于 cohort 的日收入乘以通知天数。日收入是 $72,072 ÷ 365 = $197.46。用 365 天而不是 12 个 30 天,是因为合同期通常按日历年计算;用 360 天会悄悄多算五天收入。
| 通知窗口 | 锁定收入 | 占 cohort ARR | 相当于多少天日收入 |
|---|---|---|---|
| 30 天 | $5,924 | 8.2% | 30 天 |
| 60 天 | $11,847 | 16.4% | 60 天 |
| 90 天 | $17,771 | 24.7% | 90 天 |
这张表回答的是「客户最晚什么时候说不续」的财务价值。30 天窗口只锁定 8.2% 的年度合同收入,90 天窗口锁定 24.7%。差额不是线性的销售难度,而是线性的收入保护:每多 30 天,多锁定约 $5,924。对 LedgerLite,30 天和 90 天之间的差额是 $11,847,相当于 16.4% 的 cohort ARR。
但「锁定」不等于「收到」。通知窗口只保证客户在窗口内不能单方面走人;它不保证客户在窗口结束后一定续约,也不保证客户不会在窗口内以其他理由终止。真正的收入确定性要再乘上续约概率。下一节用 1.5% 的月流失来估计这个概率。
| 口径 | 数值 | 说明 |
|---|---|---|
| 年付 cohort ARR | $72,072 | 30% × $240,240 |
| 日收入 | $197.46 | $72,072 ÷ 365 |
| 30 天保护 | $5,924 | 日收入 × 30 |
| 60 天保护 | $11,847 | 日收入 × 60 |
| 90 天保护 | $17,771 | 日收入 × 90 |
错过窗口,多留一年值多少
自动续约的核心是默认值:客户不通知,合同就续。对卖方,这意味着客户在下一个合同年里仍然是客户;对买方,这意味着如果不记得通知,就要再付一年。
先用毛收入看。年付 cohort 的 ARR 是 $72,072。如果窗口错过、合同自动续约,下一个合同年的毛收入是 $72,072。但这个数字假设客户全年不流失;LedgerLite 的月流失率是 1.5%,一年后的存活率是:
12 个月存活率 = (1 − 1.5%)^12 = 83.4%
如果只看到年末,期望收入是 $72,072 × 83.4% = $60,118。但收入是按月收到的,客户在年中流失时,前面几个月已经付过钱。更准确的口径是把每个月的存活概率加起来:
期望付费月数 = (1 − 0.985^12) ÷ 0.015 = 11.06 个月
期望收入 = $72,072 ÷ 12 × 11.06 = $66,414
这两个数字的差别,是「年末还在不在」和「全年一共收多少」的差别。前者适合做客户数预测,后者适合做收入预测。自动续约条款保护的是后者:即使客户在年中流失,前面 11 个月左右的收入已经落袋。
| 口径 | 数值 | 公式 |
|---|---|---|
| 年付 cohort ARR | $72,072 | 30% × $240,240 |
| 12 个月存活率 | 83.4% | 0.985^12 |
| 期望付费月数 | 11.06 | (1 − 0.985^12) ÷ 0.015 |
| 期望收入 | $66,414 | $6,006 × 11.06 |
| 毛利率 | 84.2% | 案例口径 |
| 期望毛利 | $55,920 | $66,414 × 84.2% |
所以「错过窗口后多留一年」的价值,不是 $72,072 的毛收入,而是 $66,414 的期望收入、$55,920 的期望毛利。这个数字还要再打一层折扣:如果客户是因为忘记通知才被续约,他可能在下一年的服务中使用得更少、支持成本更高,甚至在续约后提出退款或折扣。自动续约保留的是合同权利,不是客户满意度。
反过来,如果客户是 LedgerLite 想退出的对象——支持成本高、付款记录差、需求超出产品边界——同一句自动续约会把卖方也锁在合同里。条款是否对称,取决于终止权是否双向:客户可以不续,卖方能不能不续?如果只有客户有退出窗口,卖方没有,那么自动续约就是一条单向的保护买方、锁定卖方的条款。
自动续约还会改变现金的时间分布。年付客户在续约时预付全年费用,现金先到,收入按月确认;这笔钱在资产负债表上先是递延收入,而不是当期收入。具体口径见4.1 递延收入 。对现金流,自动续约是好事;对收入质量,它只是把未来的服务义务提前换成了现金,不能当成新增收入。
30/60/90 天的取舍
通知窗口不是越长越好。窗口越长,锁定的收入越多,但销售阻力也越大;窗口越短,合同越好签,但收入确定性越低。这个取舍在年付合同上尤其明显,因为窗口占合同期的比例直接变成客户的心理成本。
| 窗口 | 锁定收入 | 占 cohort ARR | 客户感受 | 卖方收益 |
|---|---|---|---|---|
| 30 天 | $5,924 | 8.2% | 接近「随时可以走」 | 收入保护最低,合同最好签 |
| 60 天 | $11,847 | 16.4% | 需要提前两个月规划 | 保护与阻力折中 |
| 90 天 | $17,771 | 24.7% | 相当于合同期的四分之一 | 保护最高,客户最可能要求例外 |
从 30 天走到 90 天,多锁定 $11,847,占 cohort ARR 的 16.4%。这笔钱不是白来的:客户会把这 90 天看成一种锁定,采购流程越长、预算越紧的客户,越可能要求把窗口改回 30 天,或者要求提前终止权。对 LedgerLite 这种客单价不高、客户数量多的订阅生意,90 天窗口的销售阻力可能大于它带来的收入保护;对客单价高、实施周期长的企业合同,90 天反而更常见,因为双方都需要时间做续约决策。
还有一个容易被忽略的方向:通知窗口对卖方也是约束。如果卖方想在下一年退出某个客户,或者想调整价格、停止某条产品线,长窗口意味着卖方也要提前很久通知。条款的对称性比长度更重要:客户 90 天、卖方 30 天,和双方都是 60 天,财务后果完全不同。前者把退出成本压给卖方,后者把退出成本平分。
| 条款结构 | 客户退出成本 | 卖方退出成本 | 适合的场景 |
|---|---|---|---|
| 客户 30 天 / 卖方 30 天 | 低 | 低 | 低客单价、标准化订阅 |
| 客户 90 天 / 卖方 90 天 | 高 | 高 | 高客单价、需要规划的企业合同 |
| 客户 90 天 / 卖方 30 天 | 高 | 低 | 卖方强势、客户弱势 |
| 客户 30 天 / 卖方 90 天 | 低 | 高 | 卖方被锁定,通常应避免 |
这张表说明,通知窗口不是一条孤立的条款,而是和终止权、调价权、产品下线权一起工作的。只看客户侧的 30/60/90,会漏掉卖方侧的对称成本。
代码:窗口与续约价值
下面的 renewal.ts 把通知窗口、自动续约价值和续约率风险固化成三个函数。noticeWindow 用 365 天基数计算锁定收入;autoRenewalValue 用几何级数计算 12 个月的期望收入和毛利;renewalRisk 处理没有月流失数据、只有年续约率的合同。
// file: src/contract/renewal.ts
// tier: L1
// verify: node --test test/contract/renewal.test.ts
import { type Cents } from "../core/money.ts";
import { type Bps, applyRate, divide } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";
export type RenewalInput = {
annualRevenueCents: Cents;
annualShareBps: Bps;
noticeDays: number;
monthlyChurnBps: Bps;
grossMarginBps: Bps;
};
export type NoticeWindow = {
cohortArrCents: Cents;
dailyRevenueCents: Cents;
protectedRevenueCents: Cents;
protectedShareBps: Bps;
};
export type AutoRenewalValue = {
cohortArrCents: Cents;
survivalBps: Bps;
expectedRevenueCents: Cents;
expectedGrossProfitCents: Cents;
};
export type RenewalRisk = {
annualRevenueCents: Cents;
retainedCents: Cents;
atRiskCents: Cents;
};
function checkBps(value: Bps, label: string): string | null {
if (!Number.isInteger(value) || value < 0 || value > 10000) {
return `${label} must be between 0 and 10000 bps`;
}
return null;
}
/**
* A notice window protects the revenue that the customer still owes while the
* notice runs. The base is 365 days, not twelve equal 30-day months; using a
* 360-day base quietly adds five days of revenue to every annual contract.
*/
export function noticeWindow(input: RenewalInput): Result<NoticeWindow> {
if (
!Number.isInteger(input.annualRevenueCents) ||
input.annualRevenueCents <= 0
) {
return fail("annual revenue must be a positive integer amount in cents");
}
for (const [value, label] of [
[input.annualShareBps, "annual share"],
[input.monthlyChurnBps, "monthly churn"],
[input.grossMarginBps, "gross margin"],
] as Array<[Bps, string]>) {
const error = checkBps(value, label);
if (error) {
return fail(error);
}
}
if (
!Number.isInteger(input.noticeDays) ||
input.noticeDays <= 0 ||
input.noticeDays > 365
) {
return fail("notice days must be an integer between 1 and 365");
}
const cohortArrCents = applyRate(
input.annualRevenueCents,
input.annualShareBps,
);
const protectedRevenueCents = Math.round(
(cohortArrCents * input.noticeDays) / 365,
);
return ok({
cohortArrCents,
dailyRevenueCents: Math.round(cohortArrCents / 365),
protectedRevenueCents,
protectedShareBps: divide(protectedRevenueCents, cohortArrCents),
});
}
/**
* If the notice deadline passes without a cancellation, the contract renews
* for another twelve months. Revenue is collected monthly and churn is applied
* monthly, so the expected number of paying months is a geometric series, not
* a single year-end survival test.
*/
export function autoRenewalValue(
input: RenewalInput,
): Result<AutoRenewalValue> {
const window = noticeWindow(input);
if (!window.ok) {
return window;
}
const churn = input.monthlyChurnBps / 10000;
const survival = Math.pow(1 - churn, 12);
const expectedMonths = churn === 0 ? 12 : (1 - survival) / churn;
const monthlyRevenueCents = window.value.cohortArrCents / 12;
const expectedRevenueCents = Math.round(
expectedMonths * monthlyRevenueCents,
);
return ok({
cohortArrCents: window.value.cohortArrCents,
survivalBps: Math.round(survival * 10000),
expectedRevenueCents,
expectedGrossProfitCents: applyRate(
expectedRevenueCents,
input.grossMarginBps,
),
});
}
/**
* A renewal rate is a different measurement from monthly churn: it describes
* how much of the annual contract value is expected to renew, not how many
* months a customer stays. Use it when the contract has no monthly churn data.
*/
export function renewalRisk(
annualRevenueCents: Cents,
renewalRateBps: Bps,
): Result<RenewalRisk> {
if (!Number.isInteger(annualRevenueCents) || annualRevenueCents <= 0) {
return fail("annual revenue must be a positive integer amount in cents");
}
const error = checkBps(renewalRateBps, "renewal rate");
if (error) {
return fail(error);
}
const retainedCents = applyRate(annualRevenueCents, renewalRateBps);
return ok({
annualRevenueCents,
retainedCents,
atRiskCents: annualRevenueCents - retainedCents,
});
}
代码里有三个口径值得注意。第一,noticeWindow 的基数写死为 365 天,避免调用方在 360 天和 365 天之间漂移。第二,autoRenewalValue 不是简单地把 ARR 乘以年末存活率,而是把每个月的存活概率加总;这样得到的期望收入更接近实际收款节奏。第三,renewalRisk 是另一套口径:它接受年续约率,不把续约率伪装成月流失率;两套口径不能混用。
测试里的 LedgerLite 参数是年收入 $240,240、年付比例 30%、通知窗口 30 天、月流失 1.5%、毛利率 84.2%。跑出来的 cohort ARR 是 $72,072,30 天锁定收入 $5,924,期望收入 $66,414,期望毛利 $55,920。Nord 的参数是咨询收入 $112,200、续约率 70%,得到保留收入 $78,540、风险收入 $33,660。
Nord 与 Tidecast 的对照
Nord 的咨询续约率是 70%,按咨询线年收入 $112,200 计算,下一轮能保留 $78,540,另有 $33,660 处于风险中。如果 Nord 把通知窗口从 30 天延长到 90 天,多出的 60 天可以用来处理这 $33,660,相当于多出 $5,533 的收入窗口;但咨询合同通常按月或按项目结算,长窗口的销售阻力也更明显。
Tidecast 的平台发行协议里,期限和续约由平台单方定义。卖方没有自己的续约窗口,也无法把「多留一年」写进条款;Steam 占 80% 的收入依赖,意味着平台的续约政策一变,Tidecast 的收入确定性就跟着变。对平台型收入,续约窗口不是合同条款,而是平台政策。
小结
- 通知窗口锁定的是合同期内的收入,不是续约概率;30/60/90 天分别锁定 $5,924、$11,847、$17,771,占年付 cohort ARR 的 8.2%、16.4%、24.7%。
- 年付 cohort 的基数是 $72,072(30% × $240,240),日收入 $197.46;所有窗口计算都按 365 天,不按 360 天。
- 错过窗口后多留一年的价值,不是 $72,072 的毛收入,而是 $66,414 的期望收入和 $55,920 的期望毛利。
- 期望收入用几何级数计算:期望付费月数 = (1 − 0.985^12) ÷ 0.015 = 11.06 个月,比年末存活率 83.4% 更接近实际收款。
- 窗口越长,收入保护越多,但销售阻力和卖方退出成本也越高;条款的对称性比长度更重要。
- Nord 的 70% 续约率把 $112,200 咨询收入分成 $78,540 保留与 $33,660 风险;60 天额外窗口对应 $5,533 的处理空间。
- 平台型收入的续约窗口由平台定义,卖方无法用合同条款锁定;这类收入的确定性要按平台政策而不是按合同期评估。
阅读导航:上一节:2.1 责任上限:最坏损失怎么算 · 下一节:2.3 SLA 与抵扣:可用性怎么变成钱 。
常见错误与边界
- 把通知窗口当成合同期。 合同期是收入能持续多久,通知窗口是客户最晚什么时候说不续;两者不是同一个日期。
- 用 360 天代替 365 天。 12 个 30 天会多算五天收入;30/60/90 天的数字看起来差不多,累计到全年就会漂移。
- 把锁定收入当成确定收入。 通知窗口只保证客户在窗口内不能单方面走人,不保证窗口结束后一定续约;还要乘续约概率。
- 用年末存活率代替期望收入。 年末存活率 83.4% 只回答「还在不在」,期望收入 $66,414 才回答「全年收多少」;收入预测应该用后者。
- 把毛收入当成毛利。 $72,072 是收入,$55,920 才是按 84.2% 毛利率计算的期望毛利;自动续约的价值应该按毛利比较。
- 忘记自动续约对卖方也是约束。 如果客户可以 90 天前通知、卖方只能 30 天前通知,条款是单向的;卖方退出成本被低估。
- 把续约率当成月流失率。 70% 的年续约率和 1.5% 的月流失率不是同一套口径;不能用一个去推另一个。
- 忽略年付预付的递延收入。 续约时收到的全年费用先是递延收入,不是当期收入;现金流好看不等于收入已经赚到。
- 假设平台续约条款可以谈判。 Tidecast 的平台协议由平台单方定义,卖方没有对等的续约窗口;平台政策变化会直接改变收入确定性。
- 只在合同到期前才看通知期。 通知期是一个运营日历问题:如果没有人跟踪 90/60/30 天的节点,条款写得再好也会被错过。
期限和续约的财务后果,最终落在「收入确定性」和「退出成本」两个词上。通知窗口把确定性往后推,自动续约把默认值写进合同;两者都可以用数字衡量,也都需要有人在日历上跟踪。下一节讨论 SLA 与抵扣:它不改变合同期,但会在合同期内把可用性翻译成收入折减。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。