《独立开发者的商业指南·收入与财务工程》4.2 收入质量:集中度、续费、退款

收入质量不是一个分数,而是四个可拆的维度。本节用 LedgerLite、Tidecast 与 Nord Studio 分别计算客户与渠道集中度、续费与流失、退款率和经常性收入占比,给出一个可复算的质量评分,并说明每个分项在什么情况下会失真,以及为什么高质量收入仍然可能被单一平台或单一大客户击穿。

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

收入质量回答的不是「收入有多少」,而是「这些收入明年还在不在、会不会突然少一块、要不要退回去」。同样的年收入规模,LedgerLite 的订阅、Tidecast 的买断游戏和 Nord Studio 的咨询加产品,可靠性完全不同。

这一节把收入质量拆成四个分项:集中度、续费与流失、退款、经常性占比。每个分项都换算到 0–10000 的同一把尺上,再加权成一个总分。评分不是目的,目的是让「哪一块最脆弱」变得可见。

先把三个案例的质量输入放到一张表里:

项LedgerLiteTidecastNord Studio
收入结构订阅 100%买断 100%咨询 72.7%、产品 27.3%
最大暴露口最大客户 9%Steam 渠道 80%前 3 家客户 48%
续费输入月流失 1.5%无合同续费咨询续约 70%,产品月流失 3.0%
退款率1.0%(教学假设)4.0%1.0%(教学假设)
经常性占比100.0%0.0%27.3%

表中只有 LedgerLite 和 Nord 的退款率不是事实表数字。事实表没有给出这两家的退款率,评分示例统一采用 1.0% 的教学假设,并在代码返回结果里标记 refundAssumed。这个参数可以替换;替换之后总分变化多少,本身也是判断退款重要性的方法。

收入质量不是单一指标

四个分项的定义和权重如下:

分项刻度权重回答的问题
集中度10000 − 最大暴露口占比30%少一个客户或渠道会不会伤筋动骨
续费/留存年化留存率30%现有收入明年还能留下多少
退款10000 − 退款率20%已经确认的收入要不要退回去
经常性经常性收入占比20%下一期收入有多少已经锁定

四个权重是教学参数,分别代表「不要被单点击穿」「收入能续」「不退货」「可预测」四件事。权重之和必须是 10000 个基点,不能随手归一化;改权重可以,但要明确改的是判断标准,不是数据。

总分公式:

总分 = (3000 × 集中度 + 3000 × 留存 + 2000 × 退款 + 2000 × 经常性) ÷ 10000

这个评分有三个边界。第一,它只衡量结构,不衡量增长;一家高增长但高集中的公司可能分数不高。第二,它不替代利润;Tidecast 的分数低,但 SDE 利润率 87.7%,现金并不差。第三,它依赖输入口径;集中度用最大客户、最大渠道还是前三大客户,结果会不同。

集中度:最大的暴露口

集中度分项用「10000 − 最大暴露口占比」。三个案例的口径并不相同:

案例最大暴露口占比集中度分项口径说明
LedgerLite最大单一客户9%9100事实表直接给出最大一家 9%
TidecastSteam 渠道80%2000官网占 20%,Steam 是最大渠道
Nord Studio前 3 家客户48%5200事实表只给前 3 家合计,没有单家占比

LedgerLite 的集中度分项最高,因为 260 家客户里最大一家只占 9%,前 5 家合计 31%。这不是说它没有集中风险,而是说单点风险相对分散。Tidecast 的分项只有 2000,因为 Steam 占 80%;如果按「平台依赖 100%」而不是「最大渠道 80%」来算,分项会直接变成 0。同一个案例,换一个口径就从 2000 变成 0,这就是为什么口径必须先写清楚。

Nord Studio 的 5200 最容易被误读。事实表给的是前 3 家合计 48%,不是最大一家 48%;把前 3 家占比当成单家占比,会高估集中风险。反过来,如果 Nord 的最大单家只有 20%,前 3 家 48%,用 5200 又低估了「三家一起走」的风险。客户集中度要看前五大、最大单一客户和客户流失的相关性,这些在第 7 章展开,见 7.2 集中度风险 。

集中度分项还有一个盲区:它只看静态占比,不看「这个客户或渠道有多容易替换」。LedgerLite 的最大客户占 9%,如果这家客户是标准化的会计事务所,换一家并不难;Tidecast 的 Steam 占 80%,但离开 Steam 之后官网只能承接 20%,替换成本极高。占比相同,可替换性不同,风险也不同。

续费、流失与退款

续费分项用年化留存率,而不是月流失率。月流失和年流失不能直接比较,必须统一到 12 个月:

年化留存率 = (1 − 月流失率)^12

LedgerLite 的月流失是 1.5%:

年化留存 = (1 − 0.015)^12 = 83.41%
年化流失 = 1 − 83.41% = 16.59%,与事实表的「年流失约 16.6%」一致

Nord Studio 的产品线月流失是 3.0%:

产品年化留存 = (1 − 0.03)^12 = 69.38%

Nord 还有咨询线,续约率是 70%。两条线的收入占比是 72.7% 和 27.3%,所以混合留存是:

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

换成基点就是 6983。这里要注意:咨询的 70% 是「续约率」,产品的 69.38% 是「年化留存率」,两者不是同一个指标。续约率通常指合同到期后是否续签,年化留存率还包含合同期内的流失;把两者直接平均会掩盖差异。本节把它们都当作「年度留存」的近似,并在结果里标注这是教学口径。

Tidecast 没有合同续费。买断制游戏的「留存」通常指复购或续作购买,但事实表没有复购数据,所以评分把续费分项记为 0。这不是说 Tidecast 的收入一定差,而是说「用续费这个维度衡量它」不适用;一次性生意的质量要看退款、平台集中度和长尾销量,而不是续费率。相关判断见 流失分诊 。

退款分项用「10000 − 退款率」。Tidecast 的退款率是 4%,分项是 9600;LedgerLite 和 Nord 采用 1.0% 的教学假设,分项是 9900。退款率对总分的影响被 20% 的权重稀释:退款率从 1% 升到 2%,分项从 9900 降到 9800,总分只下降 20 个基点。这不是说退款不重要,而是说单看总分会被稀释;退款要单独看趋势和集中度,尤其是退款是否集中在某个渠道或某个版本。

经常性占比与加权总分

经常性占比直接来自第 1 章的口径:

案例经常性收入总收入占比分项
LedgerLite$240,240$240,240100.0%10000
Tidecast$0$138,1710.0%0
Nord Studio$42,120$154,32027.3%2729

把四个分项代入总分公式:

案例集中度留存退款经常性总分档位
LedgerLite910083419900100009212strong
Tidecast20000960002520weak
Nord Studio52006983990027296181mixed

LedgerLite 的 9212 分来自「经常性满分 + 集中度较高 + 留存较好」;它的弱点是只有一条收入线,而且这条线依赖 260 家客户的持续付费。Tidecast 的 2520 分来自「集中度和经常性都是 0」,但它的退款分项很高,说明 4% 退款并不是主要问题;真正的问题是平台集中和一次性收入。Nord 的 6181 分居中:经常性只有 27.3%,留存 69.83%,集中度用前 3 家 48% 计算,三项都不极端。

评分和利润率是两件事。三个案例的 SDE 利润率分别是:

LedgerLite = 130,240 ÷ 240,240 = 54.2%
Tidecast   = 121,171 ÷ 138,171 = 87.7%
Nord       = 34,320 ÷ 154,320 = 22.2%

Tidecast 的质量分最低,SDE 利润率却最高;Nord 的质量分居中,SDE 利润率最低。这说明「收入质量」和「盈利能力」必须分开看:质量回答收入能不能持续,利润率回答每一美元收入能留下多少。两者都低才是危险信号,一高一低则要看阶段和策略。失败信号的组合判断见 失败信号 。

把评分写成代码

下面的 quality.ts 把四个分项固化成函数:annualRetention 把月流失年化,blendRetention 按收入占比混合多条线,scoreQuality 返回分项与总分,qualityBand 把总分映射成 strong / mixed / weak。所有比率用基点,非法输入返回 Result 错误。

// file: src/fin/quality.ts
// tier: L1
// verify: node --test test/fin/quality.test.ts

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

export type QualityWeights = {
  concentration: Bps;
  retention: Bps;
  refund: Bps;
  recurring: Bps;
};

export const DEFAULT_QUALITY_WEIGHTS: QualityWeights = {
  concentration: 3000,
  retention: 3000,
  refund: 2000,
  recurring: 2000,
};

export type RetentionInput =
  | { kind: "monthly-churn"; monthlyChurnBps: Bps }
  | { kind: "annual-renewal"; annualRetentionBps: Bps }
  | { kind: "none" };

export type QualityInput = {
  label: string;
  /** Largest known single customer or channel share, in basis points. */
  topExposureBps: Bps;
  retention: RetentionInput;
  refundRateBps: Bps;
  recurringShareBps: Bps;
  /** True when the refund rate is a teaching assumption, not a fact-table value. */
  refundAssumed?: boolean;
  assumptions?: string[];
};

export type QualityComponents = {
  concentration: Bps;
  retention: Bps;
  refund: Bps;
  recurring: Bps;
};

export type QualityScore = {
  label: string;
  components: QualityComponents;
  total: Bps;
  assumptions: string[];
};

export type QualityBand = "strong" | "mixed" | "weak";

function checkRate(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;
}

/** Annualized retention implied by a constant monthly churn rate. */
export function annualRetention(monthlyChurnBps: Bps): Result<Bps> {
  const error = checkRate(monthlyChurnBps, "monthly churn");
  if (error) {
    return fail(error);
  }
  const monthlyRetention = 1 - monthlyChurnBps / 10000;
  return ok(Math.round(Math.pow(monthlyRetention, 12) * 10000));
}

/** Revenue-weighted retention across lines with different share and renewal. */
export function blendRetention(
  parts: { shareBps: Bps; annualRetentionBps: Bps }[],
): Result<Bps> {
  if (parts.length === 0) {
    return fail("at least one retention part is required");
  }
  let shareTotal = 0;
  let weighted = 0;
  for (const part of parts) {
    const shareError = checkRate(part.shareBps, "share");
    if (shareError) {
      return fail(shareError);
    }
    const retentionError = checkRate(
      part.annualRetentionBps,
      "annual retention",
    );
    if (retentionError) {
      return fail(retentionError);
    }
    shareTotal += part.shareBps;
    weighted += part.shareBps * part.annualRetentionBps;
  }
  if (shareTotal !== 10000) {
    return fail(`shares must sum to 10000 bps, got ${shareTotal}`);
  }
  return ok(Math.round(weighted / 10000));
}

function resolveRetention(input: RetentionInput): Result<Bps> {
  if (input.kind === "none") {
    return ok(0);
  }
  if (input.kind === "monthly-churn") {
    return annualRetention(input.monthlyChurnBps);
  }
  const error = checkRate(input.annualRetentionBps, "annual retention");
  if (error) {
    return fail(error);
  }
  return ok(input.annualRetentionBps);
}

/**
 * Four-part revenue quality score, each part on a 0-10000 scale:
 *   concentration = 10000 - largest known exposure
 *   retention     = annualized renewal / retention rate
 *   refund        = 10000 - refund rate
 *   recurring     = recurring revenue share
 * The total is the weighted average of the four parts.
 */
export function scoreQuality(
  input: QualityInput,
  weights: QualityWeights = DEFAULT_QUALITY_WEIGHTS,
): Result<QualityScore> {
  if (input.label.trim() === "") {
    return fail("label must not be empty");
  }
  const exposureError = checkRate(input.topExposureBps, "top exposure");
  if (exposureError) {
    return fail(exposureError);
  }
  const refundError = checkRate(input.refundRateBps, "refund rate");
  if (refundError) {
    return fail(refundError);
  }
  const recurringError = checkRate(
    input.recurringShareBps,
    "recurring share",
  );
  if (recurringError) {
    return fail(recurringError);
  }
  const weightSum =
    weights.concentration + weights.retention + weights.refund +
    weights.recurring;
  if (weightSum !== 10000) {
    return fail(`weights must sum to 10000 bps, got ${weightSum}`);
  }
  const retention = resolveRetention(input.retention);
  if (!retention.ok) {
    return retention;
  }

  const components: QualityComponents = {
    concentration: 10000 - input.topExposureBps,
    retention: retention.value,
    refund: 10000 - input.refundRateBps,
    recurring: input.recurringShareBps,
  };
  const total = Math.round(
    (weights.concentration * components.concentration +
      weights.retention * components.retention +
      weights.refund * components.refund +
      weights.recurring * components.recurring) /
      10000,
  );

  const assumptions = [...(input.assumptions ?? [])];
  if (input.refundAssumed) {
    assumptions.push("refund rate is a teaching assumption");
  }
  if (input.retention.kind === "none") {
    assumptions.push("no contractual renewal: retention part scores zero");
  }
  return ok({
    label: input.label,
    components,
    total,
    assumptions,
  });
}

export function qualityBand(total: Bps): QualityBand {
  if (total >= 8000) {
    return "strong";
  }
  if (total >= 6000) {
    return "mixed";
  }
  return "weak";
}

代码里有四个细节。

第一,annualRetention 明确用 12 次幂,不接受「月流失 × 12」的线性近似。1.5% × 12 = 18%,而真实年化流失是 16.59%;线性近似会高估流失,也会低估留存。

第二,blendRetention 要求各线占比之和精确等于 10000 个基点。Nord 的 72.7% 和 27.3% 换成基点是 7271 和 2729,相加正好 10000;如果四舍五入后不是 10000,函数直接报错,而不是悄悄归一化。

第三,scoreQuality 把「没有合同续费」的续费分项记为 0,并自动在 assumptions 里加一条说明。这不是说 Tidecast 的客户不留存,而是说这个维度对它不适用;把「不适用」记成 0 比编一个续费率更诚实。

第四,qualityBand 的阈值 8000 和 6000 是教学参数,不是行业标准。换一套阈值,档位会变,但三个案例的相对顺序不会变;评分的作用是暴露结构,不是给出绝对好坏。

三个案例的调用结果与上面的表一致:LedgerLite 9212、Tidecast 2520、Nord Studio 6181。如果把 LedgerLite 的退款假设从 1.0% 改成 2.0%,总分从 9212 降到 9192;如果把权重改成「经常性 100%」,LedgerLite 仍然是 10000,Tidecast 仍然是 0。权重改变的是判断标准,不改变数据本身。

小结

  • 收入质量由集中度、续费/留存、退款、经常性占比四个分项组成,单看任何一个都会误判。
  • 集中度必须写清口径:最大客户、最大渠道、前三大客户不是同一个数;LedgerLite 9%、Tidecast 80%、Nord 前 3 家 48%。
  • 月流失和年流失不能直接比较:LedgerLite 月流失 1.5% 对应年化留存 83.41%,Nord 产品月流失 3.0% 对应 69.38%。
  • 退款率对总分的影响会被权重稀释,要单独看趋势;Tidecast 的 4% 是事实值,A/C 的 1.0% 是教学假设。
  • 三案例总分分别为 9212、2520、6181;分数高低不等于利润高低,Tidecast 的质量分最低但 SDE 利润率最高。
  • 评分是结构诊断工具,不是估值倍数,也不是信用评级;高分收入仍可能被单一平台或单一大客户击穿。

阅读导航:上一节:4.1 预收、递延与确认时点 · 下一节:4.3 一次性收入与经常性收入的估值差 。

常见错误与边界

  1. 把收入质量当成一个分数。 总分是四个分项的加权结果,分项之间的差异比总分更有信息量;LedgerLite 的 9212 里,集中度并不是满分。
  2. 混用最大客户和最大渠道。 LedgerLite 的最大客户 9%,Tidecast 的最大渠道 80%,两者不是同一种暴露;跨案例比较前先统一口径。
  3. 把前三大占比当单家占比。 Nord 的 48% 是前 3 家合计,不是最大一家;用它算单点风险会高估。
  4. 用月流失乘 12 当年流失。 1.5% × 12 = 18%,真实年化是 16.59%;线性近似在低流失时误差小,高流失时会明显高估。
  5. 把续约率当年化留存率。 咨询续约 70% 和产品年化留存 69.38% 的含义不同;混合时要说明近似口径。
  6. 给一次性生意硬套续费率。 Tidecast 没有合同续费,续费分项记 0 是「不适用」,不是「客户不回头」;一次性生意的质量要看退款、平台和长尾。
  7. 把退款率假设当事实。 LedgerLite 和 Nord 的 1.0% 是教学假设,事实表没有这个数;引用时必须标注参数来源。
  8. 只看总分不看利润率。 Tidecast 质量分 2520,SDE 利润率 87.7%;Nord 质量分 6181,SDE 利润率 22.2%;两者必须一起看。
  9. 权重不校验。 四个权重之和不等于 10000 时,总分失真;代码直接报错,不悄悄归一化。
  10. 把评分当估值依据。 质量分高不代表倍数高;估值还要看增长、毛利、集中度和买方视角,见第 11 章。

收入质量是「确认之后」的问题:4.1 解决一笔钱什么时候变成收入,4.2 解决变成收入之后有多可靠,4.3 则把可靠性翻译成价格,讨论为什么同样的收入规模会因为经常性与一次性的结构差异得到不同的估值区间。相关指标见 NRR 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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