《独立开发者的商业指南·收入与财务工程》6.1 涨价对收入与流失的净效应

涨价 15.6% 不等于收入涨 15.6%。本节用 LedgerLite 把 $77 提到 $89 的假设,拆出收入盈亏平衡流失率 13.5%、毛利盈亏平衡流失率 15.6%,再按 0 到 40 家客户流失做敏感性表,给出净 MRR、12 个月累计收入和价格弹性的可复算口径。

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

LedgerLite 把月费从 $77 提到 $89,涨幅是 $12,也就是 15.6%。如果 260 家客户一家不走,MRR 会从 $20,020 涨到 $23,140,增加 $3,120。涨价方案最容易在这里停住:收入账看起来只赚不赔。真正的财务问题在分母——有多少客户会走,走掉的客户带走的收入是否超过涨价带来的增量。

本节只算钱:先算涨价本身的收入增量,再算两个盈亏平衡流失率,然后用 0 到 40 家客户流失做敏感性表,最后把结果翻译成价格弹性。定价心理学、涨价沟通和页面文案已有站内文章,见 涨价执行 ;这里只回答「涨多少、走多少、净收入变多少」。

涨价的第一层账

先把涨价拆成几个数:

项旧价新价变化
月单价$77$89+$12
涨幅——15.6%
客户数2602600
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 / ARR13.5%约 35 家
毛利毛利 / SDE15.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:

流失客户流失率留存客户新 MRRMRR 变化12 个月累计变化
00.0%260$23,140+$3,120+$34,501
51.9%255$22,695+$2,675+$29,580
103.8%250$22,250+$2,230+$24,659
155.8%245$21,805+$1,785+$19,738
207.7%240$21,360+$1,340+$14,818
3513.5%225$20,025+$5+$55
4015.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。这个数假设流失发生在涨价当月,之后不再有额外的涨价冲击;它衡量的是「一次性流失 + 常态流失」的组合,不是永久稳态。

价格弹性怎么读

价格弹性 = 客户数变化百分比 ÷ 价格变化百分比。用上面的场景:

流失客户流失率价格涨幅实现弹性
51.9%15.6%−0.12
103.8%15.6%−0.25
207.7%15.6%−0.49
3513.5%15.6%−0.86
4015.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 折扣、年付与现金流 。

常见错误与边界

  1. 涨幅和平衡流失率混用分母。 涨幅是 12 ÷ 77;收入平衡流失率是 1 − 77 ÷ 89,分母是新价。两个分母混用会把 13.5% 算错。
  2. 把零流失上限当预测。 $3,120 是「一家不走」的上限;只要流失 35 家,增量就归零。
  3. 只算收入不算毛利。 走掉客户会省下变动成本,毛利平衡点比收入平衡点更宽松;LedgerLite 的差额是 2.1 个百分点。
  4. 把 12 个月累计变化乘 12。 留存客户还会继续流失,累计因子是 11.0579,不是 12。
  5. 把实现弹性当预测弹性。 弹性由一次涨价后的实际流失反推,样本只有 260 家,不能直接外推到下一次涨价。
  6. 忽略客户结构。 走掉的可能是低用量、低价值客户,MRR 的损失和 logo 的损失不是同一件事;本节只算收入账。
  7. 忽略合同与 grandfathering。 老客户是否同步涨价、年付客户是否在续约时才涨,都会改变实际流失时点;本节假设全部客户当月迁移。
  8. 忽略竞争与替代。 弹性低可能是因为迁移成本高,不是因为客户满意;这个区别影响的是长期留存,不是当月收入。
  9. 把教学参数当行业基准。 13.5%、15.6%、−0.87 都是 LedgerLite 假设下的结果,不是普适阈值。
  10. 忘了固定成本。 毛利平衡只保证毛利不变;如果涨价需要额外支持、客服或迁移成本,SDE 仍可能下降。

涨价是定价财务侧的第一问:价格动多少,客户走多少,净收入变多少。6.2 换一个方向:价格不涨,用折扣和年付换取现金流与留存;6.3 再把多条产品线放在一起,看组合结构如何改变加权客单价。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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