以下情境为教学假设,人物与数字不代表经核实的真实案例。
LedgerLite 把月费从 $77 提到 $89,涨幅是 $12,也就是 15.6%。如果 260 家客户一家不走,MRR 会从 $20,020 涨到 $23,140,增加 $3,120。涨价方案最容易在这里停住:收入账看起来只赚不赔。真正的财务问题在分母——有多少客户会走,走掉的客户带走的收入是否超过涨价带来的增量。
本节只算钱:先算涨价本身的收入增量,再算两个盈亏平衡流失率,然后用 0 到 40 家客户流失做敏感性表,最后把结果翻译成价格弹性。定价心理学、涨价沟通和页面文案已有站内文章,见 涨价执行 ;这里只回答「涨多少、走多少、净收入变多少」。
涨价的第一层账
先把涨价拆成几个数:
| 项 | 旧价 | 新价 | 变化 |
|---|---|---|---|
| 月单价 | $77 | $89 | +$12 |
| 涨幅 | — | — | 15.6% |
| 客户数 | 260 | 260 | 0 |
| MRR | $20,020 | $23,140 | +$3,120 |
| ARR | $240,240 | $277,680 | +$37,440 |
涨幅用旧价做分母:12 ÷ 77 = 15.58%,保留一位小数是 15.6%。新 MRR 用新价乘原客户数:260 × 89 = $23,140。ARR 是 MRR 乘 12:23,140 × 12 = $277,680。这三个数都假设「一家客户都不走」,所以它们不是预测,是上限。
上限有用,因为它给出了谈判的锚:涨价方案最多能带来多少收入。接下来每走一家客户,就从这 $3,120 的增量里扣掉 $89。
两个盈亏平衡流失率
涨价后收入不变的条件是「留下来的客户贡献等于原来的总收入」:
收入平衡:留存客户 × 新价 = 原客户数 × 旧价
留存客户 = 260 × 77 ÷ 89 = 224.94 → 225 家
可流失客户 = 260 − 225 = 35 家
流失率上限 = 35 ÷ 260 = 13.5%
也可以用价格直接算:1 − 77 ÷ 89 = 13.48%。这个 13.5% 是收入口径的盈亏平衡流失率;超过它,涨价反而让 MRR 下降。
毛利口径更宽松,因为走掉的客户也不再消耗变动成本。案例事实表里年度变动成本 $38,000,除以 260 家、再除以 12 个月:
每客户月变动成本 = 38,000 ÷ 260 ÷ 12 = $12.18
旧贡献 = 77 − 12.18 = $64.82
新贡献 = 89 − 12.18 = $76.82
毛利平衡流失率 = 1 − 64.82 ÷ 76.82 = 15.62%
| 口径 | 保持不变的量 | 流失率上限 | 可流失客户 |
|---|---|---|---|
| 收入 | MRR / ARR | 13.5% | 约 35 家 |
| 毛利 | 毛利 / SDE | 15.6% | 约 40 家 |
两个阈值差 2.1 个百分点,原因是每客户每月只有 $12.18 的变动成本。变动成本占比越低,两个阈值越接近;如果变动成本很高,走掉客户省下的成本会让毛利口径明显更宽松。LedgerLite 的毛利率 84.2%,所以这里只差 2 个百分点。毛利口径的 LTV 算法见 2.2 LTV 的三种算法 。
流失敏感性
把「流失客户数」当作唯一变量,其余条件不变。MRR 变化 = (260 − L) × 89 − 20,020;12 个月累计变化再乘一个留存因子。留存因子按 1.5% 月流失计算,12 个月的求和是 11.0579:
| 流失客户 | 流失率 | 留存客户 | 新 MRR | MRR 变化 | 12 个月累计变化 |
|---|---|---|---|---|---|
| 0 | 0.0% | 260 | $23,140 | +$3,120 | +$34,501 |
| 5 | 1.9% | 255 | $22,695 | +$2,675 | +$29,580 |
| 10 | 3.8% | 250 | $22,250 | +$2,230 | +$24,659 |
| 15 | 5.8% | 245 | $21,805 | +$1,785 | +$19,738 |
| 20 | 7.7% | 240 | $21,360 | +$1,340 | +$14,818 |
| 35 | 13.5% | 225 | $20,025 | +$5 | +$55 |
| 40 | 15.4% | 220 | $19,580 | −$440 | −$4,865 |
表格里有两个断点。35 家是收入平衡点,MRR 只多 $5;40 家越过收入平衡,但还没到毛利平衡(约 40 家),所以 MRR 已经为负、毛利仍可能为正。实际决策至少要同时看这两条线。
12 个月累计变化不是简单地把月变化乘 12。留下来的客户仍按 1.5% 月流失继续减少,所以累计因子是 11.0579 而不是 12。以 5 家流失为例:2,675 × 11.0579 = $29,580。这个数假设流失发生在涨价当月,之后不再有额外的涨价冲击;它衡量的是「一次性流失 + 常态流失」的组合,不是永久稳态。
价格弹性怎么读
价格弹性 = 客户数变化百分比 ÷ 价格变化百分比。用上面的场景:
| 流失客户 | 流失率 | 价格涨幅 | 实现弹性 |
|---|---|---|---|
| 5 | 1.9% | 15.6% | −0.12 |
| 10 | 3.8% | 15.6% | −0.25 |
| 20 | 7.7% | 15.6% | −0.49 |
| 35 | 13.5% | 15.6% | −0.86 |
| 40 | 15.4% | 15.6% | −0.99 |
弹性绝对值小于 1,说明客户对价格不敏感,涨价提高总收入;绝对值大于 1,说明流失带来的收入损失超过涨价增量。收入平衡点对应弹性 −0.865:13.48% ÷ 15.58% = 0.865。毛利平衡点对应弹性约 −1.00:15.62% ÷ 15.58% = 1.002。因为变动成本很低,两个平衡点在弹性上几乎重合,都在 1 附近。
这个弹性是「实现弹性」,不是「预测弹性」。它由一次涨价后的实际流失反推出来,不能直接拿去预测下一次涨价。真正要紧的是:涨价前先写下自己能承受的流失上限,涨价后用同一口径复算,而不是只看 MRR 有没有涨。
把涨价账写成代码
下面的 elasticity.ts 把本节的口径固化成四个函数:revenueNeutralLossBps 算收入平衡流失率,grossProfitNeutralLossBps 算毛利平衡流失率,breakEvenLostCustomers 算可流失客户数,priceIncreaseImpact 把 MRR、弹性、12 个月累计变化一次算完。金额用整数美分,比率用基点。
// file: src/price/elasticity.ts
// tier: L1
// verify: node --test test/price/elasticity.test.ts
import { type Cents, sub } from "../core/money.ts";
import { type Bps, divide, ratio } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";
export type PriceIncreaseInput = {
baseCustomers: number;
oldPriceCents: Cents;
newPriceCents: Cents;
lostCustomers: number;
variableCostPerCustomerCents: Cents;
monthlyChurnBps: Bps;
months: number;
};
export type PriceIncreaseImpact = {
baseMrrCents: Cents;
newMrrCents: Cents;
deltaMrrCents: Cents;
deltaMrrBps: Bps;
priceChangeBps: Bps;
lostShareBps: Bps;
revenueNeutralLossBps: Bps;
grossProfitNeutralLossBps: Bps;
breakEvenLostCustomers: number;
elasticityBps: Bps;
months: number;
cumulativeDeltaCents: Cents;
};
function isPositiveInteger(value: number): boolean {
return Number.isInteger(value) && value > 0;
}
function checkPricePair(oldPriceCents: Cents, newPriceCents: Cents): string | null {
if (!isPositiveInteger(oldPriceCents) || !isPositiveInteger(newPriceCents)) {
return "prices must be positive integer amounts in cents";
}
if (newPriceCents <= oldPriceCents) {
return "new price must be above old price";
}
return null;
}
/**
* The share of revenue that can be lost before a price increase stops
* increasing revenue. It ignores variable costs: 1 - old / new.
*/
export function revenueNeutralLossBps(
oldPriceCents: Cents,
newPriceCents: Cents,
): Result<Bps> {
const priceError = checkPricePair(oldPriceCents, newPriceCents);
if (priceError) {
return fail(priceError);
}
return ok(divide(sub(newPriceCents, oldPriceCents), newPriceCents));
}
/**
* The share of customers that can be lost before a price increase stops
* increasing gross profit. Leaving customers also stop consuming variable
* cost, so this threshold is looser than the revenue threshold.
*/
export function grossProfitNeutralLossBps(
oldPriceCents: Cents,
newPriceCents: Cents,
variableCostPerCustomerCents: Cents,
): Result<Bps> {
const priceError = checkPricePair(oldPriceCents, newPriceCents);
if (priceError) {
return fail(priceError);
}
if (
!Number.isInteger(variableCostPerCustomerCents) ||
variableCostPerCustomerCents < 0
) {
return fail("variable cost must be a non-negative integer amount in cents");
}
const newContribution = sub(newPriceCents, variableCostPerCustomerCents);
if (newContribution <= 0) {
return fail("variable cost must be below the new price");
}
return ok(
divide(sub(newPriceCents, oldPriceCents), newContribution),
);
}
/**
* Number of customers that can leave while retained revenue stays at or above
* the old revenue level. Retained customers are rounded up to whole customers.
*/
export function breakEvenLostCustomers(
baseCustomers: number,
oldPriceCents: Cents,
newPriceCents: Cents,
): Result<number> {
if (!isPositiveInteger(baseCustomers)) {
return fail("base customers must be a positive integer");
}
const priceError = checkPricePair(oldPriceCents, newPriceCents);
if (priceError) {
return fail(priceError);
}
const retained = Math.ceil((baseCustomers * oldPriceCents) / newPriceCents);
return ok(baseCustomers - retained);
}
/**
* Sum of monthly retention factors over the horizon. Month zero counts as a
* full month of revenue, so a one-month horizon returns exactly 1.
*/
function retentionSum(months: number, monthlyChurnBps: Bps): number {
const keep = 1 - ratio(monthlyChurnBps);
let sum = 0;
let factor = 1;
for (let month = 0; month < months; month += 1) {
sum += factor;
factor *= keep;
}
return sum;
}
export function priceIncreaseImpact(
input: PriceIncreaseInput,
): Result<PriceIncreaseImpact> {
const priceError = checkPricePair(input.oldPriceCents, input.newPriceCents);
if (priceError) {
return fail(priceError);
}
if (!isPositiveInteger(input.baseCustomers)) {
return fail("base customers must be a positive integer");
}
if (
!Number.isInteger(input.lostCustomers) ||
input.lostCustomers < 0 ||
input.lostCustomers > input.baseCustomers
) {
return fail("lost customers must be between zero and base customers");
}
if (
!Number.isInteger(input.variableCostPerCustomerCents) ||
input.variableCostPerCustomerCents < 0
) {
return fail("variable cost must be a non-negative integer amount in cents");
}
if (
!Number.isInteger(input.monthlyChurnBps) ||
input.monthlyChurnBps < 0 ||
input.monthlyChurnBps > 10000
) {
return fail("monthly churn must be between 0 and 10000 bps");
}
if (!isPositiveInteger(input.months)) {
return fail("months must be a positive integer");
}
const baseMrrCents = input.baseCustomers * input.oldPriceCents;
const retained = input.baseCustomers - input.lostCustomers;
const newMrrCents = retained * input.newPriceCents;
const deltaMrrCents = sub(newMrrCents, baseMrrCents);
const priceChangeBps = divide(
sub(input.newPriceCents, input.oldPriceCents),
input.oldPriceCents,
);
const lostShareBps = divide(input.lostCustomers, input.baseCustomers);
const elasticityBps =
lostShareBps === 0
? 0
: -Math.round((lostShareBps * 10000) / priceChangeBps);
const revenueThreshold = revenueNeutralLossBps(
input.oldPriceCents,
input.newPriceCents,
);
if (!revenueThreshold.ok) {
return revenueThreshold;
}
const grossProfitThreshold = grossProfitNeutralLossBps(
input.oldPriceCents,
input.newPriceCents,
input.variableCostPerCustomerCents,
);
if (!grossProfitThreshold.ok) {
return grossProfitThreshold;
}
const breakEven = breakEvenLostCustomers(
input.baseCustomers,
input.oldPriceCents,
input.newPriceCents,
);
if (!breakEven.ok) {
return breakEven;
}
return ok({
baseMrrCents,
newMrrCents,
deltaMrrCents,
deltaMrrBps: divide(deltaMrrCents, baseMrrCents),
priceChangeBps,
lostShareBps,
revenueNeutralLossBps: revenueThreshold.value,
grossProfitNeutralLossBps: grossProfitThreshold.value,
breakEvenLostCustomers: breakEven.value,
elasticityBps,
months: input.months,
cumulativeDeltaCents: Math.round(
deltaMrrCents * retentionSum(input.months, input.monthlyChurnBps),
),
});
}
代码有三个细节。第一,收入平衡用新价做分母:(新价 − 旧价) ÷ 新价,不是除以旧价;用旧价会把 13.48% 算成 15.58%。第二,毛利平衡的分子仍是涨价额,分母换成新贡献 新价 − 每客户变动成本,所以阈值比收入口径高。第三,12 个月累计变化用留存因子求和,月份从 0 开始,一个月 horizon 正好等于单月变化;它不是把单月变化乘 12。
用 LedgerLite 的参数调用,得到:收入平衡 1348 个基点(13.5%),毛利平衡 1562 个基点(15.6%),可流失 35 家;5 家流失时 MRR 增加 $2,675,实现弹性 −0.12,12 个月累计增加 $29,580。这些结果与上面的表一致。
小结
- 涨价 15.6% 的收入上限是 MRR 从 $20,020 到 $23,140,增加 $3,120;这是「零流失」上限,不是预测。
- 收入盈亏平衡流失率是 13.5%(约 35 家);毛利盈亏平衡流失率是 15.6%(约 40 家),差额来自每客户每月 $12.18 的变动成本。
- 流失 5 家时 MRR 增加 $2,675,12 个月累计增加 $29,580;流失 35 家时 MRR 只多 $5。
- 实现弹性 = 流失率 ÷ 价格涨幅;收入平衡点对应弹性约 −0.87,毛利平衡点约 −1.00。
- 涨价决策先定流失上限,再用同一口径复算;不要只看 MRR 是否上涨。
阅读导航:上一节:5.3 预算偏差与季度校准 · 下一节:6.2 折扣、年付与现金流 。
常见错误与边界
- 涨幅和平衡流失率混用分母。 涨幅是 12 ÷ 77;收入平衡流失率是 1 − 77 ÷ 89,分母是新价。两个分母混用会把 13.5% 算错。
- 把零流失上限当预测。 $3,120 是「一家不走」的上限;只要流失 35 家,增量就归零。
- 只算收入不算毛利。 走掉客户会省下变动成本,毛利平衡点比收入平衡点更宽松;LedgerLite 的差额是 2.1 个百分点。
- 把 12 个月累计变化乘 12。 留存客户还会继续流失,累计因子是 11.0579,不是 12。
- 把实现弹性当预测弹性。 弹性由一次涨价后的实际流失反推,样本只有 260 家,不能直接外推到下一次涨价。
- 忽略客户结构。 走掉的可能是低用量、低价值客户,MRR 的损失和 logo 的损失不是同一件事;本节只算收入账。
- 忽略合同与 grandfathering。 老客户是否同步涨价、年付客户是否在续约时才涨,都会改变实际流失时点;本节假设全部客户当月迁移。
- 忽略竞争与替代。 弹性低可能是因为迁移成本高,不是因为客户满意;这个区别影响的是长期留存,不是当月收入。
- 把教学参数当行业基准。 13.5%、15.6%、−0.87 都是 LedgerLite 假设下的结果,不是普适阈值。
- 忘了固定成本。 毛利平衡只保证毛利不变;如果涨价需要额外支持、客服或迁移成本,SDE 仍可能下降。
涨价是定价财务侧的第一问:价格动多少,客户走多少,净收入变多少。6.2 换一个方向:价格不涨,用折扣和年付换取现金流与留存;6.3 再把多条产品线放在一起,看组合结构如何改变加权客单价。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。