以下情境为教学假设,人物与数字不代表经核实的真实案例。
Nord Studio 有两条收入线:咨询 $85/小时,每月 110 小时;产品订阅 $39/月,90 家。两条线合计 MRR $12,860,年收入 $154,320;咨询占 72.7%,产品占 27.3%。
把两条线加起来很容易,难的是回答三个问题:加权客单价是多少,它为什么变,结构变化对 MRR 的贡献怎么拆。本节只算收入结构,不写套餐命名、页面排序和定价心理学;那些见 三档套餐设计 。
两条线,一张收入表
先按案例事实表把两条线的月收入和年收入摆在一起:
| 收入线 | 单价 | 月数量 | 月收入 | 年收入 | 收入占比 |
|---|---|---|---|---|---|
| 咨询 | $85/小时 | 110 小时 | $9,350 | $112,200 | 72.7% |
| 产品 | $39/月 | 90 家 | $3,510 | $42,120 | 27.3% |
| 合计 | — | 200 单位 | $12,860 | $154,320 | 100% |
年收入是月收入乘 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 | 加权单价 |
|---|---|---|---|---|
| 基准 | 110 | 90 | $12,860 | $64.30 |
| 产品 +30 家 | 110 | 120 | $14,030 | $61.00 |
| 咨询 +20 小时 | 130 | 90 | $14,560 | $66.18 |
| 产品涨价 $6 | 110 | 90 | $13,400 | $67.00 |
| 咨询涨价 $5 | 110 | 90 | $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 客户分层:按收入、按成本 。
常见错误与边界
- 把加权单价当报价。 $64.30 是总收入除以总数量,咨询按小时、产品按家,单位不同,不能拿去卖给任何客户。
- 用数量占比代替收入占比。 咨询数量占 55%,收入占 72.7%;用 55% 解释收入结构会低估咨询的贡献。
- 把结构效应当亏损。 产品 +30 家的结构效应是 −$759,但 MRR 仍增加 $1,170;结构效应是相对平均单价的拖累,不是绝对损失。
- 数量效应用新加权单价。 数量效应按旧加权单价算,结构效应才是残差;用新单价会把结构效应算没。
- 把残差当纯结构。 价格和数量同时变化时,残差含交互项;要看纯结构,一次只动一条线。
- 用月流失乘 12 算年化。 3.0% × 12 = 36% 是简单相加,复利口径是 30.6%;两者差 5.4 个百分点。
- 把产品占比高当收入质量高。 产品月流失 3.0% 年化 30.6%,和咨询 70% 续约率的 30% 年流失接近;质量还要看发生时点和客户集中度。
- 忽略客户集中度。 Nord 前 3 家客户占 48%,结构分析只看收入线,不看单个客户;集中度风险在 7.2 单独处理。
- 把收入结构当毛利结构。 两条线的交付成本不同,收入占比不等于毛利占比;本节只算收入账。
- 把教学案例当行业基准。 $85、$39、110 小时、90 家都是 Nord 的假设值,不是套餐设计的通用比例。
套餐组合的财务问题是「结构变了,MRR 怎么变、加权单价怎么变、风险时点怎么变」。6.1 算单一价格上调,6.2 算折扣与年付,6.3 算多线组合;三节合起来,才是定价的完整收入账。下一章从收入结构转到客户结构,讨论分层与集中度。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。