《独立开发者的商业指南·合同、应收与风险》2.2 期限、自动续约与退出窗口

自动续约把「客户要不要留下」变成「客户有没有记得通知」。本节用 LedgerLite 的 30% 年付比例和 1.5% 月流失,算出 30/60/90 天通知窗口分别锁定 $5,924、$11,847、$17,771 的年度合同收入,并把错过窗口后多留一年的期望收入 $66,414、毛利 $55,920 算清楚,再对照 Nord 的 70% 续约率。

以下情境为教学假设,人物与数字不代表经核实的真实案例。

合同期限看起来只是日历上的两个日期,真正影响现金的是三个时点:合同什么时候开始、什么时候自动续、客户最晚什么时候可以说不续。前两个时点决定收入能确认多久,第三个时点决定你在多长时间里知道收入会不会断。

自动续约把「客户要不要留下」变成了「客户有没有记得通知」。对卖方,这是一条默认保留收入的条款;对买方,这是一条默认锁定成本的条款。同一句话,在两个方向上产生完全不同的财务后果。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,9248.2%30 天
60 天$11,84716.4%60 天
90 天$17,77124.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,07230% × $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,07230% × $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,9248.2%接近「随时可以走」收入保护最低,合同最好签
60 天$11,84716.4%需要提前两个月规划保护与阻力折中
90 天$17,77124.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 与抵扣:可用性怎么变成钱 。

常见错误与边界

  1. 把通知窗口当成合同期。 合同期是收入能持续多久,通知窗口是客户最晚什么时候说不续;两者不是同一个日期。
  2. 用 360 天代替 365 天。 12 个 30 天会多算五天收入;30/60/90 天的数字看起来差不多,累计到全年就会漂移。
  3. 把锁定收入当成确定收入。 通知窗口只保证客户在窗口内不能单方面走人,不保证窗口结束后一定续约;还要乘续约概率。
  4. 用年末存活率代替期望收入。 年末存活率 83.4% 只回答「还在不在」,期望收入 $66,414 才回答「全年收多少」;收入预测应该用后者。
  5. 把毛收入当成毛利。 $72,072 是收入,$55,920 才是按 84.2% 毛利率计算的期望毛利;自动续约的价值应该按毛利比较。
  6. 忘记自动续约对卖方也是约束。 如果客户可以 90 天前通知、卖方只能 30 天前通知,条款是单向的;卖方退出成本被低估。
  7. 把续约率当成月流失率。 70% 的年续约率和 1.5% 的月流失率不是同一套口径;不能用一个去推另一个。
  8. 忽略年付预付的递延收入。 续约时收到的全年费用先是递延收入,不是当期收入;现金流好看不等于收入已经赚到。
  9. 假设平台续约条款可以谈判。 Tidecast 的平台协议由平台单方定义,卖方没有对等的续约窗口;平台政策变化会直接改变收入确定性。
  10. 只在合同到期前才看通知期。 通知期是一个运营日历问题:如果没有人跟踪 90/60/30 天的节点,条款写得再好也会被错过。

期限和续约的财务后果,最终落在「收入确定性」和「退出成本」两个词上。通知窗口把确定性往后推,自动续约把默认值写进合同;两者都可以用数字衡量,也都需要有人在日历上跟踪。下一节讨论 SLA 与抵扣:它不改变合同期,但会在合同期内把可用性翻译成收入折减。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查