《独立开发者的商业指南·收入与财务工程》7.3 续约、扩张与收缩收入

NRR 102%、GRR 83% 不是两个可以互换的数。本节用 LedgerLite 的月度 MRR 桥和年度归一化桥,拆开续约、扩张、收缩与流失,说明月度桥为什么不能直接年化,并解释 Tidecast 的买断制为什么没有 NRR、Nord 为什么只能算 GRR。

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

NRR 和 GRR 是同一批客户在两个口径下的留存:GRR 只看续约、收缩和流失,NRR 再加上扩张。它们都不是「公司收入增长率」:新签客户被排除在外,所以 NRR 102% 的公司完全可能因为新签不足而收入下降;NRR 99% 的公司也可能因为新签强劲而增长 20%。

LedgerLite 同时给了两组数:月度 MRR 桥和年度 NRR 102%、GRR 83%。这两组数不是同一个东西。月度桥是 19,280 到 20,020 的当月快照,算出来 NRR 99.79%、GRR 98.18%;年度数是 12 个月的队列结果,NRR 102%、GRR 83%。把月度桥乘以 12 或直接年化,会得到 97.51% 和 80.22%,与年度数都不相等。

第 4 章的 4.2 节把留存作为收入质量的 30% 分项,用的是年化留存(接近 GRR),而不是 NRR。本节把留存拆成四个动作、两个比率和一条时间轴;下一章 8.1 会把同样的桥接到 13 周现金流上。

三个案例的适用性差别很大:

案例NRR 是否适用可用的留存口径不能算的数
LedgerLite是NRR 102%、GRR 83%客户级扩张明细
Tidecast否退款率 4%、渠道集中NRR、GRR、复购率
Nord Studio部分咨询续约 70%、产品月流失 3.0%两条线的扩张收入

Tidecast 的「不适用」不是「留存差」,而是「没有可续约的经常性收入基数」。这个区别决定了你不能用 NRR 给它排名。

四个动作:新签、扩张、收缩、流失

留存桥把期初收入到期末收入的变化拆成四个动作:

  • 新签:本期新客户带来的收入。它改变期末余额,但不进入 NRR/GRR。
  • 扩张:现有客户加购、升级、增座带来的收入增加。它进入 NRR,不进入 GRR。
  • 收缩:现有客户降级、减座带来的收入减少。它同时进入 NRR 和 GRR。
  • 流失:现有客户完全离开带来的收入减少。它同时进入 NRR 和 GRR。

LedgerLite 的当月 MRR 桥如下:

项金额是否计入 NRR/GRR
期初 MRR$19,280基数
新签+$780(10 家 × $78)否
扩张+$310是
收缩−$60是
流失−$290(1.5%)是
期末 MRR$20,020结果

先检查桥是否闭合:19,280 + 780 + 310 − 60 − 290 = 20,020。再算两个比率:

NRR = (19,280 + 310 − 60 − 290) ÷ 19,280 = 99.79%
GRR = (19,280 − 60 − 290) ÷ 19,280 = 98.18%

存量客户的净变化是 310 − 60 − 290 = −40 美元;期末的 20,020 美元里,有 780 美元来自新签,−40 美元来自存量。整张桥的增长是 20,020 ÷ 19,280 − 1 = 3.84%,但这个 3.84% 是「总收入增长」,不是 NRR。NRR 只回答「同一批客户留下了多少」,所以它是 99.79%,不是 103.84%。

新签是否计入,是 NRR 和「收入增长率」的分界线。很多报表把两者混在一起,结果是一个靠新签撑起来的公司看起来「留存很好」,实际上存量在漏水。LedgerLite 的月度桥里,新签 780 美元把存量的 −40 美元盖住了;如果新签降到 0,期末会是 19,240 美元,总收入下降 0.21%,但 NRR 仍然是 99.79%。

NRR 与 GRR:一个看净,一个看漏

两个比率的公式只差一项:

GRR = (期初收入 − 收缩 − 流失) ÷ 期初收入
NRR = (期初收入 + 扩张 − 收缩 − 流失) ÷ 期初收入

为了看清年度口径,把 LedgerLite 的年度数归一化到 100,000 美元期初收入。案例只给了年度 GRR 83% 和 NRR 102%,没有给收缩与流失的拆分;下表的 −7,000 与 −10,000 是教学拆分,合计 −17,000,与 83% 的 GRR 一致:

项金额占期初
期初存量收入$100,000100.0%
扩张+$19,000+19.0%
收缩−$7,000−7.0%
流失−$10,000−10.0%
期末存量收入$102,000102.0%
GRR 口径(不含扩张)$83,00083.0%

这张表说明 NRR 102% 和 GRR 83% 可以同时成立:扩张补上了 17 个百分点的漏损,还多出 2 个百分点。NRR 回答「净留下多少」,GRR 回答「不靠扩张能留下多少」。前者看增长,后者看底盘。

两个指标要一起看,因为它们的组合对应不同的动作:

NRRGRR结构优先动作
≥ 110%≥ 90%扩张强、底盘稳继续加扩张,注意不要过度承诺
100%–110%80%–90%扩张补漏修流失与收缩,别让扩张掩盖问题
90%–100%≥ 90%底盘稳、扩张弱做升级、增购、年付转化
< 90%< 80%双弱先止损,再谈增长

LedgerLite 落在「扩张补漏」:NRR 102% 是稳定档,GRR 83% 是中等档。它的扩张 19% 刚好覆盖 17% 的漏损,净留存只多 2 个百分点。如果 GRR 从 83% 掉到 75%,同样的 19% 扩张只能把 NRR 拉到 94%,从「稳定」掉到「漏水」。这就是为什么 NRR 好看时仍要看 GRR:扩张不是无限的,流失是复利的。

反过来,如果 GRR 是 92%、NRR 只有 96%,问题不在流失,而在扩张和收缩。收缩(降级、减座)和流失要分开看:收缩客户的收入还在,只是变小了;流失客户的收入完全消失。两者的补救动作不同,前者靠升级路径,后者靠 流失分诊 。

期间对齐:月度桥不能直接年化

LedgerLite 的月度桥算出来 NRR 99.79%、GRR 98.18%,年度数是 NRR 102%、GRR 83%。把月度数几何年化:

月度 NRR 99.79% → 年化 99.79%^12 = 97.51%
月度 GRR 98.18% → 年化 98.18%^12 = 80.22%

三个口径放在一起:

口径NRRGRR说明
单月桥99.79%98.18%当月快照,受单月扩张与流失影响
单月几何年化97.51%80.22%假设每个月都按同一速度变化
年度队列102.00%83.00%事实表的年度结果

差异不是算错了,而是「月度桥不是恒定速率」。年度 NRR 102% 包含全年分散发生的扩张事件;单月的 310 美元扩张只是一个月的样本,不能代表 12 个月。年度 GRR 83% 是全年流失的实际结果;单月流失 1.5% 年化后是 80.22%,说明这一年的平均流失比单月略低,或者流失在月份之间不均匀。

最危险的情况是符号翻转:单月 NRR 99.79% 小于 100%,年度 NRR 102% 大于 100%。如果只看单月,会得出「存量在缩」;如果只看年度,会得出「存量在长」。两个结论都基于真实数据,但回答的是不同期间。期间不对齐,讨论就没有意义。

三条规则可以把期间对齐做对:

第一,比较 NRR/GRR 时,必须用同一期间。月度对月度,年度对年度,不能拿单月 NRR 去和年度 GRR 比。

第二,年化用几何平均,不是乘以 12。99.79% × 12 会得到 1197%,明显错误;正确做法是 99.79%^12。

第三,如果扩张是脉冲式的(大单集中在某个月),单月桥和年度队列会差很多;这时要报「期间 + 年度」两个数,并注明扩张的时间分布。

为什么游戏不能套用 SaaS 的 NRR

Tidecast 是买断制,9,600 份销量、标价 19.99 美元、退款率 4%、Steam 占 80%。它没有「期初经常性收入」这个基数:每一份拷贝在售出时确认收入,玩家下个月不会自动再付一次。没有基数,NRR 的分母就是 0,比率没有定义。

把 SaaS 的四个动作硬套到买断制,会得到四个错误类比:

SaaS 动作买断制的对应现象为什么不能类比
续约玩家继续玩、买 DLC不续约不代表收入消失,收入已经确认
扩张买续作、买 DLC这是新交易,不是同一客户加购
收缩等折扣、买低价区这是价格与渠道差异,不是降级
流失退款、差评、弃玩退款是收入冲减,不是客户流失

如果强行算一个「NRR」,只有两种结果:把基数当成 0,比率无定义;或者把续作购买算成扩张,得到一个没有分母的百分比。两种都没有财务意义。Tidecast 应该看的指标是退款率 4%、渠道集中(Steam 80%)、长尾销量曲线、续作转化率和 DLC 附着率;案例事实表没有复购数据,所以「复购 NRR」不能计算,只能作为待验证假设。

Nord Studio 处在中间:它有经常性收入,但两条线的口径不同。咨询线的续约率是 70%,这是「合同到期是否续签」,接近 GRR,但不包含合同期内的收缩;产品线的月流失是 3.0%,年化 GRR 是 (1 − 3.0%)^12 = 69.38%。按收入占比 72.7% 和 27.3% 混合:

混合 GRR = 72.7% × 70.0% + 27.3% × 69.38%
         = 50.89% + 18.94%
         = 69.83%

NRR 算不出来,因为案例没有给两条线的扩张收入。如果假设扩张为 0,NRR 就等于 GRR 69.83%;但这是假设,不是事实。咨询线的 70% 是续约率,产品线的 69.38% 是年化留存率,两者含义不同,混合值只能当近似。

案例NRRGRR 代理替代指标
LedgerLite102%83%月度 MRR 桥、扩张与收缩拆分
Tidecast不适用不适用退款率、渠道集中、续作转化
Nord Studio不可算(缺扩张)69.83% 混合咨询续约率、产品月流失

把 NRR 写成代码

下面的 nrr.ts 把桥、比率和期间对齐固化成函数:buildRetention 从五段桥计算 NRR/GRR 并校验期末余额;annualizeRetention 和 periodRetention 做几何换算;nrrBand 和 grrBand 给出档位。所有金额用整数美分,比率用基点,非法输入返回 Result 错误。

// file: src/unit/nrr.ts
// tier: L1
// verify: node --test test/unit/nrr.test.ts

import { type Cents, sub } from "../core/money.ts";
import { type Bps, divide } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";

export type RevenueBridge = {
  startingCents: Cents;
  newCents: Cents;
  expansionCents: Cents;
  contractionCents: Cents;
  churnCents: Cents;
};

export type RetentionMetrics = {
  startingCents: Cents;
  endingCents: Cents;
  newCents: Cents;
  expansionCents: Cents;
  contractionCents: Cents;
  churnCents: Cents;
  nrrBps: Bps;
  grrBps: Bps;
  expansionBps: Bps;
  contractionBps: Bps;
  churnBps: Bps;
  netChangeCents: Cents;
};

export type RetentionBand = "expanding" | "stable" | "leaking" | "critical";
export type GrrBand = "strong" | "moderate" | "weak";

function isCents(value: number): boolean {
  return Number.isInteger(value) && value >= 0;
}

/**
 * NRR and GRR exclude new customers. The starting cohort can expand,
 * contract or churn; new logos are added to the ending balance but
 * never enter the retention ratios.
 */
export function buildRetention(
  bridge: RevenueBridge,
): Result<RetentionMetrics> {
  const amounts: [string, Cents][] = [
    ["starting", bridge.startingCents],
    ["new", bridge.newCents],
    ["expansion", bridge.expansionCents],
    ["contraction", bridge.contractionCents],
    ["churn", bridge.churnCents],
  ];
  for (const [label, amount] of amounts) {
    if (!isCents(amount)) {
      return fail(`${label} must be a non-negative integer number of cents`);
    }
  }
  if (bridge.startingCents <= 0) {
    return fail("starting revenue must be positive");
  }
  if (bridge.contractionCents + bridge.churnCents > bridge.startingCents) {
    return fail("contraction plus churn cannot exceed starting revenue");
  }
  const existingChange = sub(
    bridge.expansionCents,
    bridge.contractionCents + bridge.churnCents,
  );
  const ending =
    bridge.startingCents + bridge.newCents + existingChange;
  if (ending < 0) {
    return fail("bridge produces a negative ending balance");
  }
  const retained = bridge.startingCents - bridge.contractionCents - bridge.churnCents;
  return ok({
    startingCents: bridge.startingCents,
    endingCents: ending,
    newCents: bridge.newCents,
    expansionCents: bridge.expansionCents,
    contractionCents: bridge.contractionCents,
    churnCents: bridge.churnCents,
    nrrBps: divide(retained + bridge.expansionCents, bridge.startingCents),
    grrBps: divide(retained, bridge.startingCents),
    expansionBps: divide(bridge.expansionCents, bridge.startingCents),
    contractionBps: divide(bridge.contractionCents, bridge.startingCents),
    churnBps: divide(bridge.churnCents, bridge.startingCents),
    netChangeCents: existingChange,
  });
}

/** Geometric annualization: a 99.79% month is 97.51% for the year, not 12 x 99.79%. */
export function annualizeRetention(
  periodBps: Bps,
  periodMonths: number,
): Result<Bps> {
  if (
    !Number.isInteger(periodBps) ||
    periodBps < 0 ||
    periodBps > 100000
  ) {
    return fail("period retention must be an integer between 0 and 100000 bps");
  }
  if (
    !Number.isInteger(periodMonths) ||
    periodMonths <= 0 ||
    periodMonths > 120
  ) {
    return fail("period months must be an integer between 1 and 120");
  }
  const annual = Math.pow(periodBps / 10000, 12 / periodMonths) * 10000;
  return ok(Math.round(annual));
}

/** Convert an annual retention rate to a shorter period with the same geometric path. */
export function periodRetention(
  annualBps: Bps,
  months: number,
): Result<Bps> {
  if (
    !Number.isInteger(annualBps) ||
    annualBps < 0 ||
    annualBps > 100000
  ) {
    return fail("annual retention must be an integer between 0 and 100000 bps");
  }
  if (!Number.isInteger(months) || months <= 0 || months > 120) {
    return fail("months must be an integer between 1 and 120");
  }
  const period = Math.pow(annualBps / 10000, months / 12) * 10000;
  return ok(Math.round(period));
}

export function nrrBand(nrrBps: Bps): RetentionBand {
  if (nrrBps >= 11000) return "expanding";
  if (nrrBps >= 10000) return "stable";
  if (nrrBps >= 9000) return "leaking";
  return "critical";
}

export function grrBand(grrBps: Bps): GrrBand {
  if (grrBps >= 9000) return "strong";
  if (grrBps >= 8000) return "moderate";
  return "weak";
}

代码里有四个刻意的边界。

第一,buildRetention 不把新签放进 NRR/GRR 的分子,只把它加进期末余额。这样调用方无法通过「多签新客户」美化留存;新签只影响 ending,不影响两个比率。

第二,桥必须闭合。期末余额不是外部输入,而是由期初、新签、扩张、收缩、流失算出来的;如果四个动作加起来对不上,说明桥本身有问题,函数不会替你调平。

第三,收缩加流失不能超过期初收入。超过 100% 的流失在会计上意味着负的存量收入,函数直接报错,而不是返回一个负数 NRR。

第四,年化用 Math.pow(periodBps / 10000, 12 / periodMonths),不是线性乘法。99.79% 的单月 NRR 年化是 97.51%,不是 1197%;这个差异在低流失时看起来小,在高流失时会变成数量级错误。

小结

  • NRR 和 GRR 是同一批客户的两个口径:GRR 只看收缩与流失,NRR 加上扩张;新签不进入任何一个。
  • LedgerLite 月度桥:期初 19,280、新签 780、扩张 310、收缩 60、流失 290、期末 20,020;NRR 99.79%、GRR 98.18%。
  • 年度归一化:期初 100,000、扩张 19,000、收缩加流失 17,000,NRR 102%、GRR 83%;扩张补上 17 个点的漏损。
  • 期间不能混用:单月 NRR 99.79% 年化是 97.51%,单月 GRR 98.18% 年化是 80.22%,都不是年度事实表的 102% 和 83%。
  • NRR 不是收入增长率:新签 780 美元把存量的 −40 美元盖住,总收入增长 3.84%,但 NRR 仍是 99.79%。
  • Tidecast 没有经常性基数,NRR 不适用;它该看退款率 4%、渠道集中和续作转化,案例没有复购数据。
  • Nord Studio 只能算 GRR 代理:咨询续约 70%、产品年化 69.38%,混合 69.83%;NRR 因缺扩张数据不可算。

阅读导航:上一节:7.2 集中度风险:前五大客户占比 · 下一节:8.1 13 周现金流表 。

常见错误与边界

  1. 把 NRR 当收入增长率。 NRR 排除新签;LedgerLite 总收入增长 3.84%,NRR 只有 99.79%,两者回答不同问题。
  2. 把新签算进 NRR。 新签只改变期末余额,不进入留存分子;算进去会把「多卖新客户」误当成「老客户留存好」。
  3. 用月度 NRR 直接乘 12。 99.79% × 12 是 1197%,正确年化是 99.79%^12 = 97.51%。
  4. 拿单月 NRR 和年度 GRR 比较。 期间不同,符号都可能翻转:单月 NRR 99.79% < 100%,年度 NRR 102% > 100%。
  5. 只看 NRR 不看 GRR。 NRR 102% 可能由 19% 扩张盖住 17% 漏损;GRR 83% 才是底盘。扩张一旦放缓,NRR 会迅速掉档。
  6. 把收缩和流失混在一起。 收缩客户的收入还在,只是变小;流失客户的收入完全消失。两者的补救动作不同。
  7. 给买断制硬套 NRR。 Tidecast 没有经常性基数,NRR 分母为 0;退款是收入冲减,续作是新交易,都不是续约或扩张。
  8. 把咨询续约率当 GRR。 70% 是合同到期是否续签,不包含合同期内收缩;它只能当 GRR 的近似。
  9. 给 Nord 编一个 NRR。 案例没有扩张数据;假设扩张为 0 得到的 69.83% 是假设,不是事实。
  10. 忽略扩张的脉冲性。 大单集中在某个月时,单月桥和年度队列会差很多;必须同时报期间和年度,并注明扩张的时间分布。

留存桥把「收入从谁身上来、留下多少」接到时间轴上。下一章把这根轴拉长到 13 周:应收、应付、税费和工资的现金时点,会和这里的 MRR 桥发生冲突,现金流表就是解决冲突的地方。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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