《独立开发者的商业指南·收入与财务工程》6.3 套餐组合的收入结构

套餐组合改变的不只是客单价,还有收入结构。本节用 Nord Studio 的 $85 咨询与 $39 产品,计算加权单价 $64.30 和各线收入占比,并把 MRR 变化拆成价格效应、数量效应和结构效应,说明为什么同样增加 30 个客户,收入贡献可以差出 $759。

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

Nord Studio 有两条收入线:咨询 $85/小时,每月 110 小时;产品订阅 $39/月,90 家。两条线合计 MRR $12,860,年收入 $154,320;咨询占 72.7%,产品占 27.3%。

把两条线加起来很容易,难的是回答三个问题:加权客单价是多少,它为什么变,结构变化对 MRR 的贡献怎么拆。本节只算收入结构,不写套餐命名、页面排序和定价心理学;那些见 三档套餐设计 。

两条线,一张收入表

先按案例事实表把两条线的月收入和年收入摆在一起:

收入线单价月数量月收入年收入收入占比
咨询$85/小时110 小时$9,350$112,20072.7%
产品$39/月90 家$3,510$42,12027.3%
合计—200 单位$12,860$154,320100%

年收入是月收入乘 12:9,350 × 12 = $112,200,3,510 × 12 = $42,120。收入占比用各线月收入除以总 MRR:9,350 ÷ 12,860 = 72.70%,3,510 ÷ 12,860 = 27.30%。

加权单价是总收入除以总数量:12,860 ÷ 200 = $64.30。也可以按数量权重算:咨询 110 ÷ 200 = 55%,产品 90 ÷ 200 = 45%;85 × 55% + 39 × 45% = 46.75 + 17.55 = $64.30。两个算法结果一致。

这里有一个口径边界:咨询的数量单位是小时,产品的数量单位是家,两者不是同一种东西。$64.30 是「每个计费单位的平均收入」,不是任何一个客户真实支付的价格。它的用途是观察结构变化,不是拿去做报价。

加权客单价会随结构变

固定另一条线,只动一条线,看加权单价怎么走:

场景咨询小时产品家数MRR加权单价
基准11090$12,860$64.30
产品 +30 家110120$14,030$61.00
咨询 +20 小时13090$14,560$66.18
产品涨价 $611090$13,400$67.00
咨询涨价 $511090$13,410$67.05

产品 +30 家:30 × 39 = $1,170,MRR 到 $14,030,数量到 230,加权单价 14,030 ÷ 230 = $61.00。咨询 +20 小时:20 × 85 = $1,700,MRR 到 $14,560,数量到 220,加权单价 14,560 ÷ 220 = $66.18。

产品涨价 $6:6 × 90 = $540,MRR 到 $13,400,数量仍是 200,加权单价 $67.00。咨询涨价 $5:5 × 110 = $550,MRR 到 $13,410,加权单价 $67.05。

两种变化的方向不同。增加低价产品会把 MRR 推高、把加权单价拉低;给高价咨询涨价会把加权单价推高、但不增加数量。加权单价是价格和结构的共同结果,不是可以直接设定的杠杆。只盯加权单价,会误判「产品卖得越多越差」——产品线增加 30 家,MRR 明明是上升的。

把 MRR 变化拆成三块

把 MRR 变化拆成价格效应、数量效应和结构效应:

ΔMRR = 价格效应 + 数量效应 + 结构效应
价格效应 = Σ(新价 − 旧价) × 旧数量
数量效应 = (新总数量 − 旧总数量) × 旧加权单价
结构效应 = ΔMRR − 价格效应 − 数量效应
场景ΔMRR价格效应数量效应结构效应
产品 +30 家+$1,170$0+$1,929−$759
咨询 +20 小时+$1,700$0+$1,286+$414
产品涨价 $6+$540+$540$0$0
咨询涨价 $5+$550+$550$0$0

产品 +30 家时,数量效应按旧加权单价算:30 × 64.30 = $1,929;实际 ΔMRR 只有 $1,170;结构效应是 −$759。这 −$759 就是「新增的都是 $39 的低价单位,把平均单价从 $64.30 拉到 $61.00」的代价。用新加权单价验算:(61.00 − 64.30) × 230 = −$759,对得上。

咨询 +20 小时相反:数量效应 20 × 64.30 = $1,286,实际 ΔMRR $1,700,结构效应 +$414;新增的是 $85 的高价单位,把平均单价拉高。

价格变化单独发生时,结构效应为零,因为数量没变。如果价格和数量同时变,结构效应会吸收交互项,不再等于「纯结构」;这是加法分解的边界,不是错误。要看纯结构,就一次只动一条线。

结构变化与收入质量

收入占比不等于收入质量。Nord 的咨询占 72.7%,续约率 70%,也就是一年有 30% 的客户不续;产品占 27.3%,月流失 3.0%,年化是 1 − 0.97¹² = 30.6%。两条线的年化流失几乎一样,但发生时点完全不同:

收入线占比流失口径年化流失发生时点
咨询72.7%续约率 70%30.0%集中在续约日
产品27.3%月流失 3.0%30.6%每个月都可能发生

产品收入每个月都在做留存决策,现金流更平滑,但流失是持续发生的;咨询收入一年只做一次续约决策,续约日之前收入稳定,续约日一次性暴露 30% 的风险。两者没有谁绝对更「经常性」,只是风险的时间分布不同。

这也解释了为什么不能只用「产品收入占比」判断收入质量。产品占比高会提高加权客单价的可预测性,但如果月流失是 3.0%,年化流失并不比咨询的 70% 续约率低。结构分析只能回答「收入从哪来、怎么变」,回答不了「客户集不集中、毛利高不高」。Nord 前 3 家客户占 48%,这是客户集中度问题,见 7.2 集中度风险 ;两条线的交付成本不同,毛利也要分线另算。

把结构账写成代码

下面的 tier-mix.ts 把本节的口径固化成三个函数:tierMix 算总收入、总数量、加权单价和各线收入占比,decomposeMixChange 把 MRR 变化拆成价格、数量和结构效应,annualizedChurnBps 把月流失换算成年化流失。金额用整数美分,占比和流失用基点。

// file: src/price/tier-mix.ts
// tier: L1
// verify: node --test test/price/tier-mix.test.ts

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

export type TierLine = {
  name: string;
  unitPriceCents: Cents;
  units: number;
};

export type TierMix = {
  totalCents: Cents;
  totalUnits: number;
  blendedUnitPriceCents: Cents;
  sharesBps: Bps[];
};

export type MixChange = {
  oldTotalCents: Cents;
  newTotalCents: Cents;
  deltaCents: Cents;
  oldBlendedCents: Cents;
  newBlendedCents: Cents;
  priceEffectCents: Cents;
  volumeEffectCents: Cents;
  mixEffectCents: Cents;
};

function checkLines(lines: TierLine[]): string | null {
  if (!Array.isArray(lines) || lines.length === 0) {
    return "at least one tier line is required";
  }
  const names = new Set<string>();
  for (const line of lines) {
    if (typeof line.name !== "string" || line.name.length === 0) {
      return "every tier line needs a non-empty name";
    }
    if (names.has(line.name)) {
      return "tier line names must be unique";
    }
    names.add(line.name);
    if (!Number.isInteger(line.unitPriceCents) || line.unitPriceCents < 0) {
      return "unit price must be a non-negative integer amount in cents";
    }
    if (!Number.isInteger(line.units) || line.units < 0) {
      return "units must be a non-negative integer";
    }
  }
  const totalUnits = lines.reduce((sum, line) => sum + line.units, 0);
  if (totalUnits === 0) {
    return "total units must be above zero";
  }
  return null;
}

function totalOf(lines: TierLine[]): Cents {
  return lines.reduce(
    (sum, line) => sum + line.unitPriceCents * line.units,
    0,
  );
}

function totalUnitsOf(lines: TierLine[]): number {
  return lines.reduce((sum, line) => sum + line.units, 0);
}

export function tierMix(lines: TierLine[]): Result<TierMix> {
  const lineError = checkLines(lines);
  if (lineError) {
    return fail(lineError);
  }
  const totalCents = totalOf(lines);
  const totalUnits = totalUnitsOf(lines);
  return ok({
    totalCents,
    totalUnits,
    blendedUnitPriceCents: Math.round(totalCents / totalUnits),
    sharesBps: lines.map((line) =>
      divide(line.unitPriceCents * line.units, totalCents),
    ),
  });
}

function sameShape(oldLines: TierLine[], newLines: TierLine[]): boolean {
  if (oldLines.length !== newLines.length) {
    return false;
  }
  return oldLines.every((line, index) => line.name === newLines[index].name);
}

/**
 * Additive decomposition of an MRR change into price, volume and mix effects.
 * The mix effect is the residual, so it also absorbs any interaction between
 * simultaneous price and volume changes.
 */
export function decomposeMixChange(
  oldLines: TierLine[],
  newLines: TierLine[],
): Result<MixChange> {
  const oldError = checkLines(oldLines);
  if (oldError) {
    return fail(oldError);
  }
  const newError = checkLines(newLines);
  if (newError) {
    return fail(newError);
  }
  if (!sameShape(oldLines, newLines)) {
    return fail("old and new tier lines must have the same names in order");
  }

  const oldTotalCents = totalOf(oldLines);
  const newTotalCents = totalOf(newLines);
  const oldTotalUnits = totalUnitsOf(oldLines);
  const newTotalUnits = totalUnitsOf(newLines);

  let priceEffectCents = 0;
  for (let index = 0; index < oldLines.length; index += 1) {
    priceEffectCents +=
      (newLines[index].unitPriceCents - oldLines[index].unitPriceCents) *
      oldLines[index].units;
  }

  const volumeEffectCents = Math.round(
    (newTotalUnits - oldTotalUnits) * (oldTotalCents / oldTotalUnits),
  );
  const deltaCents = newTotalCents - oldTotalCents;

  return ok({
    oldTotalCents,
    newTotalCents,
    deltaCents,
    oldBlendedCents: Math.round(oldTotalCents / oldTotalUnits),
    newBlendedCents: Math.round(newTotalCents / newTotalUnits),
    priceEffectCents,
    volumeEffectCents,
    mixEffectCents: deltaCents - priceEffectCents - volumeEffectCents,
  });
}

/**
 * Convert a monthly churn rate into the share of a cohort lost over a year.
 * A 3.0% monthly churn becomes 30.6% annual churn.
 */
export function annualizedChurnBps(monthlyChurnBps: Bps): Result<Bps> {
  if (
    !Number.isInteger(monthlyChurnBps) ||
    monthlyChurnBps < 0 ||
    monthlyChurnBps > 10000
  ) {
    return fail("monthly churn must be between 0 and 10000 bps");
  }
  return ok(
    Math.round((1 - Math.pow(1 - ratio(monthlyChurnBps), 12)) * 10000),
  );
}

代码里有三个细节。第一,tierMix 的占比用「各线收入 ÷ 总收入」,不是「数量 ÷ 总数量」;咨询的数量占比是 55%,收入占比是 72.7%,两者不能混。第二,decomposeMixChange 的数量效应用旧加权单价,结构效应是残差;单条线变化时残差就是纯结构,多条线同时变化时残差含交互项。第三,annualizedChurnBps 用复利口径 1 −(1 − 月流失)¹²,不是简单乘 12;3.0% 月流失对应 30.6% 年化,不是 36%。

用 Nord 的参数调用,得到:总收入 $12,860,总数量 200,加权单价 $64.30,咨询收入占比 72.7%、产品 27.3%;产品 +30 家时 ΔMRR $1,170,数量效应 $1,929,结构效应 −$759;咨询 +20 小时时 ΔMRR $1,700,数量效应 $1,286,结构效应 +$414。这些结果与上面的表一致。

小结

  • 两条线合计 MRR $12,860、年收入 $154,320;咨询占 72.7%,产品占 27.3%。
  • 加权单价是 $64.30,等于总收入除以总数量,也等于 85 × 55% + 39 × 45%;它是结构指标,不是报价。
  • 增加 30 家产品客户把 MRR 推高 $1,170,但把加权单价从 $64.30 拉到 $61.00,结构效应 −$759。
  • 增加 20 小时咨询把 MRR 推高 $1,700,结构效应 +$414;同样是增加数量,高价线带来正结构效应。
  • 价格变化单独发生时只有价格效应;价格和数量同时变时,结构效应会吸收交互项。
  • 产品月流失 3.0% 年化 30.6%,与咨询 70% 续约率对应的 30% 年流失接近;占比高不等于质量高,还要看发生时点和客户集中度。

阅读导航:上一节:6.2 折扣、年付与现金流 · 下一节:7.1 客户分层:按收入、按成本 。

常见错误与边界

  1. 把加权单价当报价。 $64.30 是总收入除以总数量,咨询按小时、产品按家,单位不同,不能拿去卖给任何客户。
  2. 用数量占比代替收入占比。 咨询数量占 55%,收入占 72.7%;用 55% 解释收入结构会低估咨询的贡献。
  3. 把结构效应当亏损。 产品 +30 家的结构效应是 −$759,但 MRR 仍增加 $1,170;结构效应是相对平均单价的拖累,不是绝对损失。
  4. 数量效应用新加权单价。 数量效应按旧加权单价算,结构效应才是残差;用新单价会把结构效应算没。
  5. 把残差当纯结构。 价格和数量同时变化时,残差含交互项;要看纯结构,一次只动一条线。
  6. 用月流失乘 12 算年化。 3.0% × 12 = 36% 是简单相加,复利口径是 30.6%;两者差 5.4 个百分点。
  7. 把产品占比高当收入质量高。 产品月流失 3.0% 年化 30.6%,和咨询 70% 续约率的 30% 年流失接近;质量还要看发生时点和客户集中度。
  8. 忽略客户集中度。 Nord 前 3 家客户占 48%,结构分析只看收入线,不看单个客户;集中度风险在 7.2 单独处理。
  9. 把收入结构当毛利结构。 两条线的交付成本不同,收入占比不等于毛利占比;本节只算收入账。
  10. 把教学案例当行业基准。 $85、$39、110 小时、90 家都是 Nord 的假设值,不是套餐设计的通用比例。

套餐组合的财务问题是「结构变了,MRR 怎么变、加权单价怎么变、风险时点怎么变」。6.1 算单一价格上调,6.2 算折扣与年付,6.3 算多线组合;三节合起来,才是定价的完整收入账。下一章从收入结构转到客户结构,讨论分层与集中度。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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