《独立开发者的商业指南·收入与财务工程》1.3 生命周期转型:从服务到产品、从买断到订阅

转型不是换一个收入标签,而是用一条新收入线补上旧收入线的现金。本节用 Nord Studio 的服务转产品算术,计算替代服务收入所需客户数、转型期累计现金缺口与 runway,并给出替代率、现金安全垫与组织能力三个阈值,附可运行的 transition.ts。

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

转型通常从一个很朴素的念头开始:服务太累,想做成产品;买断收入不稳,想改成订阅。念头本身没有错,错在把它当成一个「切换开关」——仿佛某一天关掉旧收入,打开新收入,生意就完成了升级。

真实的转型是一条交叉曲线:旧收入在下降,新收入在上升,两条线在某个时点交叉。交叉之前,企业要靠现金储备补差额;交叉之后,才谈得上「转型成功」。这一节把这条曲线算出来,用的仍然是案例事实表里的数字。

《独立开发者的商业指南》关心的转型主要有两条:

路径起点终点现金变化主要风险
服务转产品按小时或项目收费订阅或买断产品从「交付即收款」变成「先投入后回收」产品起量慢,服务收入先降
买断转订阅一次性售卖持续订阅从「上线集中收款」变成「按月分摊」首年现金下降,老客户不续费

Nord Studio 是服务转产品的样本:咨询 110 小时/月、85 美元/小时,月收入 9,350 美元;产品 90 家、39 美元/月,月收入 3,510 美元。咨询占 72.7%,产品占 27.3%。转型的目标不是「把咨询砍掉」,而是让产品 MRR 长到能替代咨询收入,同时不让现金断掉。

Tidecast 是买断转订阅的潜在样本,但它现在还没有订阅收入。它的 9,600 份销量、19.99 美元标价、138,171 美元净得,都是一次性收入;退款率 4%、Steam 分成 30%、Q4 占销量 38%。这些数字说明它的现金集中在上线期和假日季,改成订阅会把现金往后摊,先要熬过一段低收入期。

两条路径的算术不同,但判断框架相同:替代率、现金安全垫、组织能力。下面先用 Nord Studio 把三个阈值算清楚,再回到 Tidecast 的差异。

服务转产品:替代服务收入需要多少客户

先算目标。Nord Studio 的咨询月收入是:

咨询月收入 = 110 小时 × $85/小时 = $9,350

产品单价是 39 美元/月。如果只要求产品 MRR 替代咨询月收入,所需客户数是:

所需客户数 = ceil($9,350 ÷ $39) = ceil(239.74) = 240 家

当前已有 90 家产品客户,所以还差 150 家。接下来要算「多久能长到 240 家」。这里需要一个月度增长假设:产品月流失 3.0%,每月毛新增 15 家(这是教学参数,不是案例事实表数字)。客户数的递推是:

C(t) = C(t−1) − round(C(t−1) × 3.0%) + 15
MRR(t) = C(t) × $39

代入后,关键月份的客户数与 MRR 如下:

月份客户数产品 MRR与咨询月收入的距离
090$3,510−$5,840
3126$4,914−$4,436
6159$6,201−$3,149
9189$7,371−$1,979
12216$8,424−$926
15241$9,399+$49

第 15 个月,产品 MRR 达到 9,399 美元,首次超过咨询月收入 9,350 美元。注意这里的目标只是「替代咨询收入」,不是「替代总收入」。Nord Studio 当前总收入是 9,350 + 3,510 = 12,860 美元;如果咨询归零、产品只长到 9,399 美元,总收入会比现在少 3,461 美元。

如果目标是保持总收入不变,产品 MRR 需要达到 12,860 美元,所需客户数是 ceil(12,860 ÷ 39) = 330 家。按同样的流失和新增假设,达到 330 家需要 29 个月,第 29 个月是 331 家、MRR 12,909 美元。

两个目标对应两个时间尺度:替代服务线 15 个月,替代总收入 29 个月。转型计划必须先说清楚是哪一个,否则「转型完成」会变成一个没有定义的词。

转型期现金缺口

产品增长的同时,咨询不会一直停在 110 小时。假设咨询在 12 个月内线性退出:

咨询月收入(t) = $9,350 × max(0, 12 − t) ÷ 12

第 1 个月还有 11/12 的咨询收入,第 12 个月归零。把咨询收入和产品 MRR 相加,再与当前总收入 12,860 美元比较:

月缺口(t) = max(0, $12,860 − 咨询收入(t) − 产品 MRR(t))
累计缺口 = Σ 月缺口(t)

关键月份的缺口如下。金额单位为美元,保留两位小数;合计与缺口由精确值相减。

月份咨询收入产品 MRR合计收入月缺口
1$8,570.83$3,978.00$12,548.83$311.17
3$7,012.50$4,914.00$11,926.50$933.50
6$4,675.00$6,201.00$10,876.00$1,984.00
9$2,337.50$7,371.00$9,708.50$3,151.50
12$0.00$8,424.00$8,424.00$4,436.00
15$0.00$9,399.00$9,399.00$3,461.00

缺口先扩大、后收窄:咨询退出速度快于产品补位速度,第 12 个月咨询归零、缺口达到 4,436 美元的峰值;第 13 个月起产品继续增长,缺口才开始下降。15 个月的累计缺口是 38,398 美元,平均每月 2,560 美元。

Nord Studio 的可支配现金是 25,740 美元(税后)。按平均缺口算:

缺口 runway = floor($25,740 ÷ $2,560) = 10 个月
资金缺口 = $38,398 − $25,740 = $12,658

10 个月的 runway 小于 15 个月的替代周期,转型会在第 10 个月左右耗尽现金,距离完成还差约 12,658 美元。如果按「完全没有收入」的绝对口径算,月度现金支出按两位合伙人工资 96,000 美元/年加直接成本 24,000 美元/年估算,合计 120,000 美元/年,即 10,000 美元/月;25,740 ÷ 10,000 = 2.57,只能撑 2 个完整月。绝对 runway 更短,但它假设收入瞬间归零,适合做压力测试,不适合描述正常转型。

两种 runway 的差距说明:转型的风险不是「产品做不出来」,而是「产品做出来的速度慢于现金消耗的速度」。要补上 12,658 美元的缺口,要么加快产品增长,要么延后咨询退出,要么先留出更多现金。

三个阈值

把上面的算术收成三个阈值。它们不是精确的开关,而是转型前必须回答的三个问题。

替代率

替代率 = 产品 MRR ÷ 服务月收入。Nord Studio 在第 15 个月达到 9,399 ÷ 9,350 = 100.5%。如果目标是保持总收入,替代率要按总收入算:12,860 ÷ 9,350 = 137.5%,也就是产品 MRR 要达到服务收入的 1.375 倍。

替代率低于 100% 时,转型还在「补缺口」阶段;达到 100% 只是替代了服务线,达到 137.5% 才替代了总收入。把这两个数写进计划,比写「产品收入超过服务收入」更不容易自欺。

现金安全垫

累计缺口 38,398 美元是转型的现金成本。安全垫至少覆盖这个数;更保守的做法是再加 20% 缓冲,即 38,398 × 1.2 = 46,078 美元;或者用「缺口 + 3 个月固定支出」= 38,398 + 30,000 = 68,398 美元。

Nord Studio 现在只有 25,740 美元,低于任何一个口径。转型前要补的现金是 12,658 美元(覆盖缺口)、20,338 美元(覆盖 20% 缓冲)或 42,658 美元(覆盖缺口加 3 个月固定成本)。这三个数字都可以从事实表复算,不是拍脑袋的「多留点钱」。

组织能力

前两个阈值是财务的,第三个是人的。Nord Studio 有两位合伙人,咨询和产品都要人:谁负责产品增长,谁继续交付咨询?如果两个人同时转产品,咨询收入会掉得比模型更快;如果一个人守咨询、一个人做产品,产品增长可能慢于每月 15 家。

招聘第三人是另一条路,但成本是 55,000 美元/年;案例事实表给出的覆盖条件是新增收入至少 75,000 美元,才能覆盖工资与税。把这条约束放回模型:如果第三人在产品 MRR 达到 9,350 美元之前加入,现金缺口会再增加 55,000 美元/年,runway 会更短。组织能力不是「有没有人」,而是「新增的人能不能在现金耗尽前产生足够收入」。

服务产品化的通用条件,包括标准化、可复用资产和交付边界,见 服务产品化的条件 ;服务线的退出节奏见 服务收尾 。本节只算财务后果,不重复操作步骤。

买断转订阅的不同之处

Tidecast 的转型方向不是服务转产品,而是买断转订阅。它的现金结构和 Nord Studio 相反:买断是上线期集中收款,订阅是把同样的收入摊到未来。转型的第一个后果是首年现金下降,因为原本一次性收到的 19.99 美元,变成了每月几美元的订阅。

Tidecast 的数字里,有几个约束不能忽略:

  • 9,600 份销量、19.99 美元标价,净得 138,171 美元;退款率 4%、Steam 分成 30%、官网支付费 5%。
  • Q4 占全年销量 38%,12 月 2,400 份、1 月 480 份;订阅化会抹平季节峰值,也会削掉上线期的大额现金。
  • 买断玩家中有多少愿意转订阅,案例事实表没有给出;这个数不能拍脑袋补,必须用真实转化率验证。

因此,Tidecast 的转型不应该从「把买断改成订阅」开始,而应该从「哪些内容适合持续付费」开始:续作、资料片、关卡包、授权,都是比整体转订阅更贴近现有资产的路径。案例事实表给的 IP 授权报价是保底 60,000 美元/年 + 8% 净收入,3 年最低保证 180,000 美元;这条路径不要求把 9,600 个买断玩家转成订阅用户,而是把 IP 授权给能持续运营的第三方。

如果一定要转订阅,至少要回答三个问题:现有买断用户中愿意续费的比例是多少?订阅内容的生产成本是多少?首年现金下降后,现金储备能撑多久?这三个问题没有案例数字支撑时,任何模型都只是假设。订阅生命周期的操作细节见 订阅生命周期 。

把转型写成代码

下面的 transition.ts 把三个量固化成函数:replacementMrr 算替代服务收入所需客户数与月数,transitionGap 算转型期累计现金缺口,runwayMonths 算现金能撑几个完整月。客户数按整数递推,金额用整数美分。

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

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

export type ReplacementInput = {
  targetMrrCents: Cents;
  productPriceCents: Cents;
  currentCustomers: number;
  monthlyChurnBps: Bps;
  grossAddsPerMonth: number;
  maxMonths?: number;
};

export type Replacement = {
  targetMrrCents: Cents;
  customersNeeded: number;
  monthsToReplace: number;
  finalCustomers: number;
  finalMrrCents: Cents;
  series: Cents[];
};

export type GapPoint = {
  month: number;
  serviceCents: Cents;
  productCents: Cents;
  totalCents: Cents;
  gapCents: Cents;
};

export type GapResult = {
  points: GapPoint[];
  totalGapCents: Cents;
  averageGapCents: Cents;
  months: number;
};

function validate(input: ReplacementInput): Result<number> {
  if (!Number.isInteger(input.targetMrrCents) || input.targetMrrCents <= 0) {
    return fail("target MRR must be a positive integer number of cents");
  }
  if (!Number.isInteger(input.productPriceCents) || input.productPriceCents <= 0) {
    return fail("product price must be a positive integer number of cents");
  }
  if (!Number.isInteger(input.currentCustomers) || input.currentCustomers < 0) {
    return fail("current customers must be a non-negative integer");
  }
  if (
    !Number.isInteger(input.monthlyChurnBps) ||
    input.monthlyChurnBps < 0 ||
    input.monthlyChurnBps > 10000
  ) {
    return fail("monthly churn must be an integer between 0 and 10000 bps");
  }
  if (
    !Number.isInteger(input.grossAddsPerMonth) ||
    input.grossAddsPerMonth < 0
  ) {
    return fail("gross adds must be a non-negative integer");
  }
  const maxMonths = input.maxMonths ?? 120;
  if (!Number.isInteger(maxMonths) || maxMonths <= 0) {
    return fail("maxMonths must be a positive integer");
  }
  return ok(maxMonths);
}

/**
 * Customers needed so that product MRR reaches targetMrrCents, and the number
 * of months to get there assuming a fixed monthly gross-add count and churn.
 */
export function replacementMrr(input: ReplacementInput): Result<Replacement> {
  const checked = validate(input);
  if (!checked.ok) {
    return checked;
  }
  const customersNeeded = Math.ceil(
    input.targetMrrCents / input.productPriceCents,
  );
  let customers = input.currentCustomers;
  let mrr = customers * input.productPriceCents;
  if (mrr >= input.targetMrrCents) {
    return ok({
      targetMrrCents: input.targetMrrCents,
      customersNeeded,
      monthsToReplace: 0,
      finalCustomers: customers,
      finalMrrCents: mrr,
      series: [],
    });
  }
  const series: Cents[] = [];
  for (let month = 1; month <= checked.value; month++) {
    const churned = Math.round(
      (customers * input.monthlyChurnBps) / 10000,
    );
    customers = Math.max(0, customers - churned + input.grossAddsPerMonth);
    mrr = customers * input.productPriceCents;
    series.push(mrr);
    if (mrr >= input.targetMrrCents) {
      return ok({
        targetMrrCents: input.targetMrrCents,
        customersNeeded,
        monthsToReplace: month,
        finalCustomers: customers,
        finalMrrCents: mrr,
        series,
      });
    }
  }
  return fail(`target not reached within ${checked.value} months`);
}

/**
 * Monthly cash gap while service revenue runs off linearly and product MRR
 * follows productSeries. gap = max(0, baseline - service - product).
 */
export function transitionGap(
  baselineMonthlyCents: Cents,
  serviceMonthlyCents: Cents,
  serviceRunoffMonths: number,
  productSeries: Cents[],
): Result<GapResult> {
  if (!Number.isInteger(baselineMonthlyCents) || baselineMonthlyCents <= 0) {
    return fail("baseline revenue must be a positive integer number of cents");
  }
  if (!Number.isInteger(serviceMonthlyCents) || serviceMonthlyCents < 0) {
    return fail("service revenue must be a non-negative integer number of cents");
  }
  if (
    !Number.isInteger(serviceRunoffMonths) ||
    serviceRunoffMonths <= 0
  ) {
    return fail("service runoff must be a positive number of months");
  }
  if (productSeries.length === 0) {
    return fail("product series must not be empty");
  }
  for (const mrr of productSeries) {
    if (!Number.isInteger(mrr) || mrr < 0) {
      return fail("product series values must be non-negative integers");
    }
  }
  const points: GapPoint[] = productSeries.map((productCents, index) => {
    const month = index + 1;
    const remaining = Math.max(0, serviceRunoffMonths - month);
    const serviceCents = Math.round(
      (serviceMonthlyCents * remaining) / serviceRunoffMonths,
    );
    const totalCents = serviceCents + productCents;
    return {
      month,
      serviceCents,
      productCents,
      totalCents,
      gapCents: Math.max(0, sub(baselineMonthlyCents, totalCents)),
    };
  });
  const totalGapCents = points.reduce((acc, point) => acc + point.gapCents, 0);
  return ok({
    points,
    totalGapCents,
    averageGapCents: Math.round(totalGapCents / points.length),
    months: points.length,
  });
}

/** Full months of cash at a constant monthly gap; Infinity when the gap is zero. */
export function runwayMonths(
  cashCents: Cents,
  monthlyGapCents: Cents,
): Result<number> {
  if (!Number.isInteger(cashCents) || cashCents < 0) {
    return fail("cash must be a non-negative integer number of cents");
  }
  if (!Number.isInteger(monthlyGapCents) || monthlyGapCents < 0) {
    return fail("monthly gap must be a non-negative integer number of cents");
  }
  if (monthlyGapCents === 0) {
    return ok(Number.POSITIVE_INFINITY);
  }
  return ok(Math.floor(cashCents / monthlyGapCents));
}

代码里的增长假设是显式参数:grossAddsPerMonth 和 monthlyChurnBps。这两个数不是案例事实,而是计划输入;换一组假设,月数和缺口都会变。把假设放在参数里,而不是写死在公式里,是为了让读者能一眼看到「结论依赖什么」。

transitionGap 的咨询退出也是显式参数 serviceRunoffMonths。案例里用 12 个月,是因为它和产品 15 个月的替代周期接近;如果咨询退出更快,缺口会更大,runway 会更短。代码不替读者选择退出速度,只负责把选择后的现金后果算出来。

runwayMonths 用平均缺口做分母,返回完整月数;当缺口为 0 时返回 Infinity,表示现金不会被缺口消耗。它不处理收入波动、税期和应收账期,这些会在第 8 章的压力测试里补齐,相关方法见 8.3 runway 与现金底线 。

小结

  • 转型是一条交叉曲线:旧收入下降、新收入上升,交叉前靠现金补差额。
  • Nord Studio 替代咨询月收入 9,350 美元需要 240 家产品客户;从 90 家、每月净增约 12 家出发,第 15 个月达到 241 家、MRR 9,399 美元。
  • 如果要保持总收入 12,860 美元不变,需要 330 家客户,按同样假设要 29 个月。
  • 咨询 12 个月线性退出、产品 15 个月替代,累计现金缺口 38,398 美元,平均每月 2,560 美元。
  • 可支配现金 25,740 美元只能支撑约 10 个月的缺口,距离 15 个月转型还差 12,658 美元。
  • 三个阈值:替代率 100%(替代服务线)或 137.5%(替代总收入)、现金安全垫覆盖累计缺口、组织能力能支撑产品增长。

阅读导航:上一节:1.2 收入模型组合:主收入与第二收入线 · 下一节:2.1 单位经济的四个数:ARPA、毛利、CAC、回本 。

常见错误与边界

  1. 把「替代服务收入」当成「替代总收入」。 Nord Studio 产品 MRR 达到 9,350 美元只是替代咨询线;总收入仍是 12,860 美元,产品要到 12,860 美元才持平。
  2. 忽略咨询退出的速度。 咨询 12 个月线性退出时累计缺口 38,398 美元;退出越快,缺口越大,runway 越短。
  3. 用绝对 runway 描述正常转型。 25,740 美元按 10,000 美元/月固定成本只能撑 2 个月,但转型期仍有收入;绝对 runway 是压力测试,不是计划值。
  4. 把增长假设当事实。 每月毛新增 15 家是教学参数,不是案例事实;换成 10 家,替代月数会显著拉长。
  5. 忘记流失。 产品月流失 3.0%,每月新增 15 家中有约 3 家只是在补流失;净增约 12 家,不是 15 家。
  6. 现金安全垫只算一个月。 累计缺口 38,398 美元是 15 个月的总量;只留一个月缺口会在第 2 个月就断粮。
  7. 在产品 MRR 不足时招第三人。 第三人成本 55,000 美元/年,需要新增收入至少 75,000 美元才能覆盖;提前招聘会扩大缺口。
  8. 把买断转订阅当成开关。 Tidecast 的 9,600 份销量、Q4 38% 的季节性会在订阅化后消失;首年现金下降,必须有储备。
  9. 用未经验证的转化率建模。 买断玩家转订阅的比例不在案例事实表里;没有真实数据时,任何「转化率 10%」都是假设,不是预测。
  10. 把转型完成定义成「产品上线」。 上线只是起点;替代率、现金缺口和组织能力都过关,才叫完成转型。

转型不是一次性的战略选择,而是一段有明确现金成本的过渡期。第 2 章会把单位经济拆成四个数,帮助判断产品线是否值得继续投入;第 8 章会把 runway 和压力测试做成月度模型。本节的任务,是把「转型要花多少钱、要多久」从感觉变成可复算的数字。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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