《独立开发者的商业指南·收入与财务工程》6.2 折扣、年付与现金流

年付折扣不只是让利 15%。本节以 LedgerLite 的年付方案和 Tidecast 的渠道结构为例,算清等效月单价、提前收款、递延余额、支付费节省与真实资金成本,并给出折扣后维持净收入所需的销量增幅,以及年付客群需要换回的流失下降幅度。

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

年付 15% 折扣看起来只是把 $924 的年费降到 $785.40。财务上它同时做了三件事:把未来 12 个月的收入提前收到今天,把一部分收入变成递延负债,用价格换更长的留存。三件事的方向不一致,所以不能只用「让利 15%」一个数判断。本节先算年付折扣的等效月单价和现金流,再算递延余额,最后用 Tidecast 的渠道分成说明折扣在不同渠道下的净得差异。定价心理学和折扣策略见 折扣与终身优惠 ;支付通道配置不在本节,收入确认口径见 4.1 预收、递延与确认时点 。

年付折扣的等效价格

LedgerLite 的月付标价是 $77,年付给 15% 折扣:

项月付年付
标价$77/月$924/年
折扣—15%
实收$77/月$785.40/年
等效月单价$77.00$65.45
每客户年收入$924$785.40
相对月付100%85%

年价是 77 × 12 = $924;折后 924 × 85% = $785.40;等效月单价是 785.40 ÷ 12 = $65.45。折扣率与等效月单价的换算很直接:

年付折扣年价等效月价相对月付
0%$924.00$77.00100%
15%$785.40$65.4585%
30%$646.80$53.9070%

LedgerLite 有 30% 的年付客户,也就是 260 × 30% = 78 家。把这 78 家单独拿出来看:

项金额
年付实收$61,261.20
同客户按月付标价$72,072.00
让利$10,810.80
首月现金(年付)$61,261.20
首月现金(月付)$6,006.00
提前收款$55,255.20
签约时递延$61,261.20

78 家年付客户一次性带来 $61,261.20 现金,而月付只会在首月带来 $6,006。差额 $55,255.20 是「提前收款」,不是「多出来的收入」。同样的 78 家客户按年付价格全年只确认 $61,261.20,比月付标价少 $10,810.80,这就是 15% 折扣的真实价格。

现金提前量与递延负债

把一家年付客户的 12 个月拆开,现金和收入不是同一条线:

时点收到现金当月确认收入递延余额累计确认收入
签约$785.40$0.00$785.40$0.00
第 1 个月末$0.00$65.45$719.95$65.45
第 11 个月末$0.00$65.45$65.45$719.95
第 12 个月末$0.00$65.45$0.00$785.40

签约时收到的 $785.40 是负债,不是当期收入;每个月确认 $65.45,递延余额同步减少。案例事实表里的年末递延收入 $22,000 是全部年付 cohort 在某个时点的快照,不等于本节这个 78 家新 cohort 的 $61,261.20;两者口径不同,不能直接对比。

提前收款有真实价值,但它要先覆盖折扣成本。按支付费 5%、资金成本 8% 计算:

项金额占年付标价
让利$10,810.8015.0%
支付费节省(12 笔变 1 笔)$540.540.8%
资金成本收益(提前收款)$2,108.752.9%
净经济成本$8,161.5111.3%

支付费节省来自笔数:月付一年 12 笔,每笔按 $77 的 5% 收 $3.85,全年 $46.20;年付一笔按 $785.40 的 5% 收 $39.27,省 $6.93。资金成本收益是把 12 笔月付按 8% 年化折现,再与签约时一次性收款比较,78 家合计约 $2,109。两项加起来只回收约 3.7 个百分点,15% 的折扣还剩下约 11.3 个百分点的净成本。

年付要买回多少流失

年付折扣的合理性最终落在留存上。用毛利口径的 LTV 做基准:月付贡献是 77 × 84.2% = $64.80,按 1.5% 月流失,LTV = 64.80 ÷ 1.5% = $4,320。

年付的等效月收入是 $65.45,贡献是 65.45 × 84.2% = $55.11。如果年付客户的月流失仍是 1.5%,LTV 只有 55.11 ÷ 1.5% = $3,674,刚好低 15%。要让年付 LTV 回到 $4,320,月流失必须降到:

可承受月流失 = 55.11 ÷ 4,320 = 1.28%

1.28% 是 128 个基点,对应年流失约 14.2%,而月付基准是 16.6%。也就是说,年付方案至少要把月等效流失相对降低约 14.9%,15% 的折扣才不亏。把支付费节省和资金收益也算进去,贡献升到约 $57.49,可承受月流失约 1.33%,需要的相对降幅收窄到约 11%。

口径月贡献目标 LTV可承受月流失
月付标价$64.80$4,3201.50%(现状)
年付等效,不含节省$55.11$4,3201.28%
年付等效,含支付与资金$57.49$4,3201.33%

如果年付客户的流失没有明显低于月付,15% 折扣就是把 15% 的收入转给客户;如果年付确实把月等效流失压到 1.33% 以下,这笔折扣才买到东西。这里的 LTV 是简化口径,没有计入扩张收入和 NRR 102%,也没有折现未来现金流;它只回答「折扣需要换回多少留存」。

Tidecast:渠道分成下的折扣

Tidecast 的标价是 $19.99,Steam 占 80% 销量、分成 30%,官网占 20%、支付费 5%,退款率 4%。先看不同折扣下的单份净得:

折扣标价Steam 净得/份官网净得/份混合净得/份9,600 份净得保本销量增幅
0%$19.99$13.43$18.23$14.39$138,1710%
10%$17.99$12.09$16.41$12.95$124,35411.1%
20%$15.99$10.75$14.58$11.51$110,53725.0%
30%$13.99$9.40$12.76$10.07$96,72042.9%

净得按「标价 ×(1 − 折扣)×(1 − 退款 4%)×(1 − 渠道费)」计算。Steam 的渠道费是 30%,官网是 5%。每让出 $1 标价,Steam 净得减少 0.96 × 0.70 = $0.672,官网净得减少 0.96 × 0.95 = $0.912;按 80/20 混合,整体净得减少约 $0.72。渠道分成越高,折扣对你越「便宜」,因为平台也按比例承担了折扣。

保本销量增幅 = 折扣 ÷(1 − 折扣)。10% 折扣需要多卖 11.1%,20% 需要多卖 25.0%,30% 需要多卖 42.9%。20% 折扣如果只带来 10% 的销量增长,9,600 份变成 10,560 份,净得约 $121,590,仍比不打折的 $138,171 少约 $16,581。折扣不是「卖得更多就一定更赚」,要先过保本销量线。

把折扣账写成代码

下面的 discount.ts 把本节的口径固化成五组函数:annualPlanPrice 与 equivalentMonthlyPrice 算年付等效价,annualDiscountImpact 算让利、提前收款、支付费节省、资金收益和净经济成本,deferredBalance 算递延余额,requiredMonthlyChurnBps 算年付可承受的流失上限,channelNetTotal 与 unitLiftToHoldNetBps 算渠道净得和保本销量。

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

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

const MONTHS_PER_YEAR = 12;

export type AnnualDiscountInput = {
  monthlyPriceCents: Cents;
  customers: number;
  discountBps: Bps;
  paymentFeeBps: Bps;
  costOfCapitalBps: Bps;
};

export type AnnualDiscountImpact = {
  annualListPerCustomerCents: Cents;
  annualPricePerCustomerCents: Cents;
  equivalentMonthlyCents: Cents;
  annualListCents: Cents;
  annualRevenueCents: Cents;
  giveUpCents: Cents;
  firstMonthMonthlyCashCents: Cents;
  cashPullForwardCents: Cents;
  deferredAtSigningCents: Cents;
  paymentSavingsCents: Cents;
  floatBenefitCents: Cents;
  netEconomicCostCents: Cents;
};

function isPositiveInteger(value: number): boolean {
  return Number.isInteger(value) && value > 0;
}

function checkRate(value: Bps, label: string): string | null {
  if (!Number.isInteger(value) || value < 0 || value > 10000) {
    return `${label} must be between 0 and 10000 bps`;
  }
  return null;
}

export function annualPlanPrice(
  monthlyPriceCents: Cents,
  discountBps: Bps,
): Result<Cents> {
  if (!isPositiveInteger(monthlyPriceCents)) {
    return fail("monthly price must be a positive integer amount in cents");
  }
  const rateError = checkRate(discountBps, "discount");
  if (rateError) {
    return fail(rateError);
  }
  return ok(
    applyRate(monthlyPriceCents * MONTHS_PER_YEAR, 10000 - discountBps),
  );
}

export function equivalentMonthlyPrice(annualPriceCents: Cents): Result<Cents> {
  if (!isPositiveInteger(annualPriceCents)) {
    return fail("annual price must be a positive integer amount in cents");
  }
  return ok(Math.round(annualPriceCents / MONTHS_PER_YEAR));
}

/** Elapsed month zero is signing, when the whole plan is still deferred. */
export function deferredBalance(
  annualPriceCents: Cents,
  months: number,
  elapsedMonths: number,
): Result<Cents> {
  if (!isPositiveInteger(annualPriceCents)) {
    return fail("annual price must be a positive integer amount in cents");
  }
  if (!isPositiveInteger(months)) {
    return fail("months must be a positive integer");
  }
  if (
    !Number.isInteger(elapsedMonths) ||
    elapsedMonths < 0 ||
    elapsedMonths > months
  ) {
    return fail("elapsed months must be between zero and the plan length");
  }
  return ok(
    Math.round((annualPriceCents * (months - elapsedMonths)) / months),
  );
}

/** Present value of twelve equal payments at the annual cost of capital. */
function presentValueOfMonthlyPayments(
  annualCents: Cents,
  annualRateBps: Bps,
): number {
  const monthlyRate = Math.pow(1 + ratio(annualRateBps), 1 / MONTHS_PER_YEAR) - 1;
  const payment = annualCents / MONTHS_PER_YEAR;
  let presentValue = 0;
  for (let month = 0; month < MONTHS_PER_YEAR; month += 1) {
    presentValue += payment / Math.pow(1 + monthlyRate, month);
  }
  return presentValue;
}

export function annualDiscountImpact(
  input: AnnualDiscountInput,
): Result<AnnualDiscountImpact> {
  if (!isPositiveInteger(input.monthlyPriceCents)) {
    return fail("monthly price must be a positive integer amount in cents");
  }
  if (!isPositiveInteger(input.customers)) {
    return fail("customers must be a positive integer");
  }
  for (const [value, label] of [
    [input.discountBps, "discount"],
    [input.paymentFeeBps, "payment fee"],
    [input.costOfCapitalBps, "cost of capital"],
  ] as const) {
    const rateError = checkRate(value, label);
    if (rateError) {
      return fail(rateError);
    }
  }

  const annualListPerCustomerCents = input.monthlyPriceCents * MONTHS_PER_YEAR;
  const annualPricePerCustomerCents = applyRate(
    annualListPerCustomerCents,
    10000 - input.discountBps,
  );
  const equivalentMonthlyCents = Math.round(
    annualPricePerCustomerCents / MONTHS_PER_YEAR,
  );
  const annualListCents = annualListPerCustomerCents * input.customers;
  const annualRevenueCents = annualPricePerCustomerCents * input.customers;
  const giveUpCents = annualListCents - annualRevenueCents;
  const firstMonthMonthlyCashCents =
    input.monthlyPriceCents * input.customers;
  const cashPullForwardCents =
    annualRevenueCents - firstMonthMonthlyCashCents;

  const monthlyPaymentFee =
    applyRate(input.monthlyPriceCents, input.paymentFeeBps) * MONTHS_PER_YEAR;
  const annualPaymentFee = applyRate(
    annualPricePerCustomerCents,
    input.paymentFeeBps,
  );
  const paymentSavingsCents =
    (monthlyPaymentFee - annualPaymentFee) * input.customers;

  const presentValue =
    presentValueOfMonthlyPayments(
      annualPricePerCustomerCents,
      input.costOfCapitalBps,
    ) * input.customers;
  const floatBenefitCents = Math.round(annualRevenueCents - presentValue);
  const netEconomicCostCents =
    giveUpCents - paymentSavingsCents - floatBenefitCents;

  return ok({
    annualListPerCustomerCents,
    annualPricePerCustomerCents,
    equivalentMonthlyCents,
    annualListCents,
    annualRevenueCents,
    giveUpCents,
    firstMonthMonthlyCashCents,
    cashPullForwardCents,
    deferredAtSigningCents: annualRevenueCents,
    paymentSavingsCents,
    floatBenefitCents,
    netEconomicCostCents,
  });
}

/** Monthly churn a cohort can tolerate while its LTV matches a target. */
export function requiredMonthlyChurnBps(
  contributionCents: Cents,
  targetLtvCents: Cents,
): Result<Bps> {
  if (!Number.isInteger(contributionCents) || contributionCents < 0) {
    return fail("contribution must be a non-negative integer amount in cents");
  }
  if (!isPositiveInteger(targetLtvCents)) {
    return fail("target LTV must be a positive integer amount in cents");
  }
  return ok(divide(contributionCents, targetLtvCents));
}

/** Net cash for a batch after discount, refunds and the channel fee. */
export function channelNetTotal(
  listPriceCents: Cents,
  units: number,
  discountBps: Bps,
  refundBps: Bps,
  channelFeeBps: Bps,
): Result<Cents> {
  if (!isPositiveInteger(listPriceCents)) {
    return fail("list price must be a positive integer amount in cents");
  }
  if (!isPositiveInteger(units)) {
    return fail("units must be a positive integer");
  }
  for (const [value, label] of [
    [discountBps, "discount"],
    [refundBps, "refund"],
    [channelFeeBps, "channel fee"],
  ] as const) {
    const rateError = checkRate(value, label);
    if (rateError) {
      return fail(rateError);
    }
  }
  const listTotal = listPriceCents * units;
  const afterDiscount = applyRate(listTotal, 10000 - discountBps);
  const afterRefund = applyRate(afterDiscount, 10000 - refundBps);
  return ok(applyRate(afterRefund, 10000 - channelFeeBps));
}

export function averageNetPerUnit(
  totalCents: Cents,
  units: number,
): Result<Cents> {
  if (!Number.isInteger(totalCents) || totalCents < 0) {
    return fail("total must be a non-negative integer amount in cents");
  }
  if (!isPositiveInteger(units)) {
    return fail("units must be a positive integer");
  }
  return ok(Math.round(totalCents / units));
}

/** A 20% discount needs 25% more units: 0.20 / (1 - 0.20). */
export function unitLiftToHoldNetBps(discountBps: Bps): Result<Bps> {
  if (
    !Number.isInteger(discountBps) ||
    discountBps < 0 ||
    discountBps >= 10000
  ) {
    return fail("discount must be between 0 and 9999 bps");
  }
  if (discountBps === 0) {
    return ok(0);
  }
  return ok(divide(discountBps, 10000 - discountBps));
}

代码里有三个口径。第一,所有比率用基点,金额用整数美分;annualDiscountImpact 在批次总额上应用折扣、退款和渠道费,避免逐份四舍五入累积误差。第二,递延余额按「年价 × 剩余月数 ÷ 12」线性摊开,签约时等于年价,第 12 个月末归零;它不处理提前退款和升级,那些是 4.1 的边界。第三,requiredMonthlyChurnBps 返回的是「可承受上限」,不是预测值;实际月流失低于它,年付 LTV 才不低于目标。

用 LedgerLite 的参数调用,得到:年价 $785.40,等效月价 $65.45;78 家年付客户让利 $10,810.80,提前收款 $55,255.20,净经济成本 $8,161.51,占年付标价 11.3%;年付月流失上限约 128 个基点。Tidecast 在 20% 折扣下净得 $110,537,保本销量增幅 25.0%。这些结果与上面的表一致。

小结

  • 年付 15% 折扣把 $924 年费降到 $785.40,等效月单价 $65.45;78 家年付客户让利 $10,810.80。
  • 提前收款 $55,255.20 是现金,不是收入;签约时全额递延,之后每月确认 $65.45。
  • 支付费节省 $540.54、资金成本收益 $2,108.75,合计回收约 3.7 个百分点,净经济成本仍占年付标价 11.3%。
  • 年付 LTV 要追平月付的 $4,320,月等效流失需从 1.5% 降到约 1.28%;计入节省后约 1.33%。
  • 渠道折扣的保本销量增幅是 折扣 ÷(1 − 折扣):20% 折扣需要多卖 25%,30% 需要多卖 42.9%。

阅读导航:上一节:6.1 涨价对收入与流失的净效应 · 下一节:6.3 套餐组合的收入结构 。

常见错误与边界

  1. 把提前收款当收入。 年付首月收到 $61,261.20,但当期收入只有 $5,105.10;差额是递延负债。
  2. 只算折扣率,不算净经济成本。 15% 让利要先扣支付费节省和资金收益,LedgerLite 的净成本是 11.3%,不是 15%。
  3. 把不同 cohort 的递延余额直接相加。 年末 $22,000 是全部年付客户的快照,不等于某个新 cohort 的签约递延。
  4. 忘了退款和拒付。 年付一次性收款后仍可能退款;退款会同时减少现金和递延,不能只冲收入。
  5. 用月付流失直接套年付。 年付一年只有一次续约决策,月等效流失与月付不同;要比较的是年化后的流失。
  6. 忽略扩张收入。 本节 LTV 没有计入 NRR 102%;如果年付客户扩张明显,1.28% 的阈值可以放宽。
  7. 把保本销量增幅当销量预测。 25% 是 20% 折扣的保本线,不是折扣一定带来的增量。
  8. 忽略渠道分成。 Steam 30% 分成下,折扣的一部分由平台承担;官网 5% 支付费下,几乎全部由你承担。
  9. 忽略现金流的时间价值。 提前收款的价值取决于资金成本;8% 是一个教学参数,不是所有独立开发者的机会成本。
  10. 把年付折扣当万能留存工具。 如果产品没有形成使用习惯,年付只是把流失推迟到续约日,续约时仍会集中暴露。

年付折扣是「用价格换时间和留存」:现金提前到账,收入按月确认,折扣是否划算取决于流失下降能否覆盖净成本。6.3 把视角从单一价格移到多条产品线,讨论套餐组合如何改变加权客单价和收入结构。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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