《独立开发者的商业指南·收入与财务工程》8.2 情景模拟:断供、退款潮、平台封禁

情景模拟不预测未来,只回答如果发生会怎样。本节把大客户流失与获客断供、平台封禁、退款率从 4% 放大到 12%、工资与税费集中到期四类冲击,分别代入三个案例的 13 周现金流表,量化每一类冲击的现金缺口、到达时间与可承受上限,并给出组合冲击的排序方法。

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

13 周表回答「按现在的节奏,未来一个季度哪一周最紧」。情景模拟回答下一个问题:如果某个假设不成立,这张表会变成什么样。两者的区别不是精确度,而是目的:周表求准,情景求排序。你不需要预测平台一定封禁,只需要知道封禁发生时会先缺多少钱、多久之后缺、以及有没有动作窗口。

情景模拟最常见的失败不是算错,而是算得太晚。等到大客户提出解约才开始改表,现金已经进入下坡;等到退款率翻倍才去查原因,退款已经从结算里扣走。可用的做法是提前把冲击写成参数,让参数一改,表就重算。本节把四类冲击拆成可复算的输入,再用三个案例验证它们各自的承压上限。

四个参数:把冲击写成可复算的输入

任何冲击最终只改变两件事:某一周的流入变少,或某一周的流出变多。把这两件事再拆,就得到四个参数。参数化的好处是每个数字都能追溯到案例事实,而不是拍脑袋给一个「下滑 30%」。

参数含义作用方式本案取值示例
永久折减从第 1 周起就不再回来的收入占比每周流入乘以(1 − 基点/10000)LedgerLite 最大客户 9% 加新签归零 3.9%,合计 12.9%
冲击折减从第 N 周起额外消失的收入占比第 N 周之后叠乘Tidecast 失去 Steam 的 80% 收入
每周新增支出从第 N 周起新增的固定付款第 N 周之后加到流出Nord 招聘第三人 55,000/年,每周 1,058
一次性收支只在某一周发生的一笔直接加减税款、追补退款、未续约的年付

两个公式贯穿全节。第一,损失 = 基线金额 × 基点 ÷ 10,000;基点把「9%」写成 900,避免小数在表格里漂移。第二,退款增量 = 毛销售 ×(压力退款率 − 基线退款率)。Tidecast 的基线退款率是 4%,压力值是 12%,增量就是毛销售的 8%。

情景不是把最坏情况一次全上。一次只改一个参数,才能看出哪一类冲击真正致命;把四个参数同时拉满,得到的数字很大,但它不告诉你先处理哪一个。下面的顺序是:先看单点冲击,再看组合,最后按「到达时间 × 缺口大小」排序。

LedgerLite:大客户流失与获客断供

LedgerLite 的前五大客户占收入 31%,最大一家占 9%。9% 的 MRR 是 20,020 × 9% = 1,802 美元/月。同期新签是每月 780 美元;如果获客断供,这 780 也不再进来。两项合计 2,582 美元/月,占 MRR 的 12.9%,即 1,290 基点。

把 1,290 基点作为永久折减代入 13 周表:常规周流入从 3,234 降到 2,817,周净额从 −36 变成 −453。年付续约和税款仍按原节奏发生。

周期初流入流出净额期末
1$95,0002,8173,270−$453$94,547
294,5472,8173,270−$453$94,094
394,0942,8173,270−$453$93,641
493,6412,8173,270−$453$93,188
593,1882,8173,270−$453$92,735
692,73518,5103,270$15,240$107,975
7107,9752,8173,270−$453$107,522
8107,5222,8173,270−$453$107,069
9107,0692,81715,211−$12,394$94,675
1094,6752,8173,270−$453$94,222
1194,2222,8173,270−$453$93,769
1293,7692,8173,270−$453$93,316
1393,3162,8173,270−$453$92,863

13 周后现金从 95,000 降到 92,863,只少 2,137;最低点 92,735 出现在第 5 周,也就是年付续约之前。也就是说,最大客户流失加获客断供,在 13 周窗口里只让 LedgerLite 少了一层薄薄的缓冲,远没有到伤筋动骨。

把冲击放大到极限:前五大客户全部流失,31% 的收入永久消失,折 3,100 基点,每周流入降到 2,231,周净额 −1,039。13 周后现金 81,985,最低点就是第 13 周的 81,985。仍然高于 8.3 节会算出的 44,941 水位。

这就是 260 家客户、最大一家 9% 的结构价值:集中度是估值问题,不是偿付问题。同样的 31%,如果发生在一家只有 8 个客户的公司,就是生死问题。情景模拟的第一条结论因此不是「LedgerLite 很安全」,而是「它的安全来自客户数,不来自客户质量」。

Tidecast:平台封禁与退款潮

Tidecast 的风险结构完全相反。Steam 占销量 80%,平台是单一渠道,没有替代。封禁不是一个概率问题,而是一个「什么时候」的问题。把封禁写成情景:从第 5 周起失去 Steam 的全部收入,官网的 20% 保留。

单份净得会从 14.39 掉到 3.34,只剩基线的 23.2%。原因是 Steam 的 13.43 美元净得归零,官网的 18.23 美元还要乘 20% 的销量占比。再叠加退款率从 4% 升到 12%:官网单份净得从 18.23 降到 16.71,加权后仍是 3.34 左右。

现金影响有 30 天延迟。第 5 周封禁,第 9 周才在结算里体现;这四周是动作窗口,也是错觉窗口——账上还在收封禁前的钱,看起来一切正常。

周期初流入流出净额期末
1$92,0902,073327$1,746$93,836
293,8362,073327$1,746$95,582
395,5822,073327$1,746$97,328
497,3282,073327$1,746$99,074
599,0742,0732,559−$486$98,588
698,5882,073327$1,746$100,334
7100,3347,974327$7,647$107,981
8107,9817,974327$7,647$115,628
9115,6281,852327$1,525$117,153
10117,1531,852327$1,525$118,678
11118,6781,852327$1,525$120,203
12120,203371327$44$120,247
13120,247371327$44$120,291

第 5 周的流出 2,559 里有一笔 2,232 的追补退款:前四周出货 1,396 份、标价 27,906,退款率从 4% 升到 12%,多退 8%,即 2,232。退款是即时现金流出,不等结算周期,所以它比封禁更早出现在表里。

这张表最容易被误读的地方是期末 120,291,比期初还高。原因是 12 月的销售在 1 月才到账,而封禁要到第 9 周才影响回款。真正的信号在第 12 周以后:每周流入 371、流出 327,净额 44,等于说这门生意还在,但已经不再产生现金。

把这段节奏年化:官网 20% 的销量、12% 的退款率,年净得约 32,086;减营销 12,000、服务器 3,000、工具 2,000,SDE 从 121,171 掉到约 15,086。现金没有立刻归零,但生意从「可出售的资产」变成了「刚好覆盖成本的副业」。

退款潮单独发生时没有那么致命。4% 到 12% 让年净得从 138,171 降到 126,657,少 11,514,约 8.3%;分摊到每月不到 1,000。它的危险在于和封禁同时发生:封禁砍掉收入基数,退款潮再从剩下的基数里扣一层,两个 20% 叠乘之后只剩 23%。

Nord 与组合冲击:工资、税费与到达顺序

Nord Studio 的冲击不在收入端,在支出端。它的 13 周基线是每周 +660,第 8 周有一笔 8,580 的税款,把现金从 30,360 压到 22,440。如果从第 5 周起招聘第三人,成本是 55,000/年,折每周 1,058,周净额从 +660 变成 −398。

周期初流入流出净额期末
1$25,7402,9682,308$660$26,400
226,4002,9682,308$660$27,060
327,0602,9682,308$660$27,720
427,7202,9682,308$660$28,380
528,3802,9683,366−$398$27,982
627,9822,9683,366−$398$27,584
727,5842,9683,366−$398$27,186
827,1862,96811,946−$8,978$18,208
918,2082,9683,366−$398$17,810
1017,8102,9683,366−$398$17,412
1117,4122,9683,366−$398$17,014
1217,0142,9683,366−$398$16,616
1316,6162,9683,366−$398$16,218

13 周后现金 16,218,比基线少 9,522。招聘本身只花 9 周 × 1,058 = 9,522,正好等于差额;税款不是新增的,它只是把低点压得更低。如果招聘之后再丢一个月咨询,再少 9,350:第 8 周的税款周低点降到 8,858,期末降到 6,868,现金只剩水位的一个零头。

Nord 的招聘还有一层约束:案例事实表说,招聘第三人需要新增收入至少 75,000 才能覆盖工资与税。55,000 的工资加上税负与毛利结构,正好落在这个量级。13 周表能看到现金少了 9,522,但看不到「这 9,522 需要 75,000 的新收入才补得回来」;后者是招聘决策的门槛,不是周表的输出。

把三案例的冲击放在一张表里,排序就出来了:

案例情景13 周现金变化期末现金最低点相对基线
LedgerLite基线+$5,609$100,609$94,820(第 5 周)—
LedgerLite最大客户加获客断供−$2,137$92,863$92,735(第 5 周)−$7,746
LedgerLite前五大全部流失−$13,015$81,985$81,985(第 13 周)−$18,624
Tidecast基线+$51,253$143,343$92,090(期初)—
TidecastSteam 封禁加退款 12%+$28,201$120,291$92,090(期初)−$23,052
Nord Studio基线$0$25,740$22,440(第 8 周)—
Nord Studio招聘第三人−$9,522$16,218$16,218(第 13 周)−$9,522
Nord Studio招聘并丢一个月咨询−$18,872$6,868$8,858(第 8 周)−$18,872

排序不是按缺口大小,而是按「缺口什么时候到达」和「有没有动作窗口」。LedgerLite 的缺口最小,但它的动作窗口最长,因为客户还在、续约还在、渠道还在。Tidecast 的 13 周缺口 23,052 看起来可控,但它的动作窗口只有一个结算周期;封禁一旦生效,能改的只有官网占比和退款率,而这两个变量都不在 13 周内翻盘。

Nord 的缺口绝对值不算大,危险等级却最高,因为它的现金本来就贴着水位线。9,522 的额外支出对 LedgerLite 是一层缓冲,对 Nord 是把水位从 22,440 压到 16,218。同样的绝对数字,在不同基数上是不同的性质。

组合冲击的原则因此是:先处理「缺口到达最早」的那一个,再处理「缺口占现金比例最大」的那一个。前者决定你有没有时间,后者决定你还剩多少。两个维度都指向同一个动作时,不要犹豫;指向不同动作时,先保时间。

把情景写成代码

下面的 scenarios.ts 把四个参数固化成一次变换:输入 13 周基线和情景规格,输出改写后的周表,再交给 buildCash13 计算期末与最低点。参数全部用基点,一次性收支用带符号的美分,退款增量单独提供一个函数。

// file: src/cash/scenarios.ts
// tier: L1
// verify: node --test test/cash/scenarios.test.ts

import { type Cents } from "../core/money.ts";
import { type Result, fail, ok } from "../core/result.ts";
import {
  type Cash13,
  type WeekFlow,
  buildCash13,
  WEEKS_PER_QUARTER,
} from "./cash13.ts";

export const BPS = 10_000;

/**
 * A stress scenario is described by four independent knobs so that each one can
 * be sourced from a different case fact:
 *   permanentLossBps   a share of every week's inflow that never comes back
 *   shockLossBps       an extra share removed from startWeek onward
 *   weeklyOutflowDelta a recurring payment added from startWeek onward
 *   oneOffInflow/outflow  a single lump in a named week (renewal, tax, refund)
 * All shares are basis points; 10,000 bps is a total loss.
 */
export type ScenarioSpec = {
  name: string;
  permanentLossBps: number;
  shockLossBps: number;
  startWeek: number;
  weeklyOutflowDelta: Cents;
  oneOffInflow: Cents;
  oneOffInflowWeek: number;
  oneOffOutflow: Cents;
  oneOffOutflowWeek: number;
};

export type ScenarioOutcome = Cash13 & {
  name: string;
  lostInflow: Cents;
};

export function defaultSpec(name: string): ScenarioSpec {
  return {
    name,
    permanentLossBps: 0,
    shockLossBps: 0,
    startWeek: 1,
    weeklyOutflowDelta: 0,
    oneOffInflow: 0,
    oneOffInflowWeek: 1,
    oneOffOutflow: 0,
    oneOffOutflowWeek: 1,
  };
}

/** Rounded share of an amount, so 900 bps of 20,020 keeps whole cents. */
export function share(amount: Cents, bps: number): Cents {
  return Math.round((amount * bps) / BPS);
}

/**
 * Extra refund cash when the refund rate moves from baseBps to stressBps.
 * Tidecast: 400 bps to 1,200 bps is 8% of gross sales handed back.
 */
export function refundDelta(
  grossSales: Cents,
  baseRefundBps: number,
  stressRefundBps: number,
): Result<Cents> {
  if (!Number.isInteger(grossSales) || grossSales < 0) {
    return fail("gross sales must be a non-negative integer number of cents");
  }
  if (
    !Number.isInteger(baseRefundBps) ||
    !Number.isInteger(stressRefundBps) ||
    baseRefundBps < 0 ||
    stressRefundBps < 0 ||
    stressRefundBps > BPS
  ) {
    return fail("refund rates must be integers between 0 and 10,000 bps");
  }
  if (stressRefundBps <= baseRefundBps) {
    return fail("stress refund rate must exceed the base rate");
  }
  return ok(share(grossSales, stressRefundBps - baseRefundBps));
}

function validateSpec(spec: ScenarioSpec): Result<true> {
  const rates = [spec.permanentLossBps, spec.shockLossBps];
  for (const rate of rates) {
    if (!Number.isInteger(rate) || rate < 0 || rate > BPS) {
      return fail("loss rates must be integers between 0 and 10,000 bps");
    }
  }
  if (spec.permanentLossBps + spec.shockLossBps > BPS) {
    return fail("combined loss cannot exceed 10,000 bps");
  }
  if (
    !Number.isInteger(spec.startWeek) ||
    spec.startWeek < 1 ||
    spec.startWeek > WEEKS_PER_QUARTER
  ) {
    return fail(`start week must be between 1 and ${WEEKS_PER_QUARTER}`);
  }
  if (!Number.isInteger(spec.weeklyOutflowDelta)) {
    return fail("weekly outflow delta must be an integer");
  }
  if (!Number.isInteger(spec.oneOffInflow) || !Number.isInteger(spec.oneOffOutflow)) {
    return fail("one-off amounts must be integers");
  }
  return ok(true);
}

/**
 * Rewrites a base week series. Losses compound: the permanent share is removed
 * first, then the shock share applies to what is left. The one-off inflow is
 * signed, so a renewal that failed to land is a negative number.
 */
export function applyScenario(
  base: WeekFlow[],
  spec: ScenarioSpec,
): Result<WeekFlow[]> {
  const checked = validateSpec(spec);
  if (!checked.ok) {
    return checked;
  }
  const out: WeekFlow[] = [];
  for (let i = 0; i < base.length; i += 1) {
    const week = i + 1;
    const keepPermanent = BPS - spec.permanentLossBps;
    const keepShock = week >= spec.startWeek ? BPS - spec.shockLossBps : BPS;
    let inflow = Math.round(
      (base[i].inflow * keepPermanent * keepShock) / (BPS * BPS),
    );
    if (week === spec.oneOffInflowWeek) {
      inflow += spec.oneOffInflow;
    }
    let outflow = base[i].outflow;
    if (week >= spec.startWeek) {
      outflow += spec.weeklyOutflowDelta;
    }
    if (week === spec.oneOffOutflowWeek) {
      outflow += spec.oneOffOutflow;
    }
    out.push({ inflow, outflow });
  }
  return ok(out);
}

/** Applies a scenario and runs it through the 13-week table. */
export function runScenario(
  openingCash: Cents,
  base: WeekFlow[],
  spec: ScenarioSpec,
): Result<ScenarioOutcome> {
  const applied = applyScenario(base, spec);
  if (!applied.ok) {
    return applied;
  }
  const built = buildCash13(openingCash, applied.value);
  if (!built.ok) {
    return built;
  }
  const baseInflow = base.reduce((acc, row) => acc + row.inflow, 0);
  return ok({
    ...built.value,
    name: spec.name,
    lostInflow: baseInflow - built.value.totalInflow,
  });
}

代码里有两个容易被忽略的约定。第一,折减是叠乘而不是相加:先扣永久折减,再扣冲击折减,因此 12.9% 和 80% 同时存在时,保留的是 87.1% × 20%,而不是 7.1%。第二,一次性流入带符号,未续约的年付写成负数,和「退款追补」这种流出分开记,避免把两类不同性质的事混在一个字段里。

退款增量的函数只算差额,不重算整张表:毛销售 27,906 美元乘以 800 基点,得到 2,232 美元,也就是 223,248 美分。这个数量级差异是基点最容易出错的地方——share 返回的是美分,而表里展示的是美元。

情景跑完不是终点。8.3 节会把「期末现金」换成「还能撑几个月」,把「最低点」换成「离水位线还差多少」。周表看周,情景看事件,runway 看月份,三者是同一组数字的三种时间尺度。

小结

  • 四类冲击可以拆成四个参数:永久折减、从第 N 周起的冲击折减、每周新增支出、单周一次性收支;全部用基点,避免小数漂移。
  • LedgerLite 的最大客户 9% 加新签归零 3.9%,13 周只让现金从 95,000 降到 92,863;前五大全部流失也只到 81,985,集中度是估值问题,不是偿付问题。
  • Tidecast 的 Steam 封禁有 30 天结算延迟,第 5 周封禁、第 9 周才见现金;期末 120,291 看似安全,第 12 周后周净额只剩 44,年化 SDE 从 121,171 掉到约 15,086。
  • 退款率从 4% 升到 12% 单独发生只少 11,514 年净得,约 8.3%;危险在于和封禁叠乘,两个折减之后只剩 23%。
  • Nord 招聘第三人每周多 1,058,13 周少 9,522;再丢一个月咨询 9,350,期末只剩 6,868,第 8 周低点 8,858,而它的现金本来只有 25,740。
  • 组合冲击按「到达时间」和「占现金比例」两个维度排序:先保时间,再保余量。

阅读导航:上一节:8.1 13 周现金流表 · 下一节:8.3 runway 与最低现金水位 。

常见错误与边界

  1. 把情景当预测。 情景不是「预计会跌 30%」,而是「如果跌 30%,第几周会缺钱」;它的输出是排序和动作窗口,不是概率。
  2. 折减相加而不是叠乘。 12.9% 加 80% 不是 92.9%;封禁后再退款,保留的是 87.1% × 20%,两个百分比作用在不同的基数上。
  3. 忽略结算延迟。 Tidecast 第 5 周封禁,现金到第 9 周才变;按封禁当周记缺口,会误判动作窗口。
  4. 把退款当收入折减。 退款是即时现金流出,且退款率变化同时影响已售和将售;它和「少卖几份」的现金流时点不同。
  5. 只跑最坏组合。 四个参数同时拉满只能得到一个吓人的数字;单点冲击才能告诉你先处理哪一个。
  6. 忘记一次性项目的符号。 未续约的年付是流入的减项,税款和追补退款是流出的加项;符号反了,情景会变成利好。
  7. 用美元和基点混算。 share 返回美分,表格展示美元;乘除 100 的位置错一次,结论就差两个数量级。
  8. 把 13 周缺口当全部损失。 封禁的 13 周缺口 23,052 只是开始;真正的损失是之后的年化收入,要交给 runway 和估值去算。
  9. 忽略招聘的收入门槛。 Nord 的第三人每周多 1,058,但需要新增 75,000 年收入才覆盖;周表只显示支出,不显示这个门槛。
  10. 情景不更新。 参数要随合同、渠道和退款政策变化重跑;三个月前的情景,可能已经不描述今天的生意。

本节的边界是「单点冲击加线性折减」:它假设冲击在一周内发生、之后保持恒定,不模拟渐变、恢复和二次冲击。真实世界里的封禁可能申诉成功,退款潮可能两周后回落,大客户可能只走一半。这些非线性需要用多个情景包络,而不是一个精确值。下一节把情景的期末现金接到 runway 与最低水位,回答「还能撑多久」和「什么时候必须动作」。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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