以下情境为教学假设,人物与数字不代表经核实的真实案例。
年付 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.00 | 100% |
| 15% | $785.40 | $65.45 | 85% |
| 30% | $646.80 | $53.90 | 70% |
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.80 | 15.0% |
| 支付费节省(12 笔变 1 笔) | $540.54 | 0.8% |
| 资金成本收益(提前收款) | $2,108.75 | 2.9% |
| 净经济成本 | $8,161.51 | 11.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,320 | 1.50%(现状) |
| 年付等效,不含节省 | $55.11 | $4,320 | 1.28% |
| 年付等效,含支付与资金 | $57.49 | $4,320 | 1.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,171 | 0% |
| 10% | $17.99 | $12.09 | $16.41 | $12.95 | $124,354 | 11.1% |
| 20% | $15.99 | $10.75 | $14.58 | $11.51 | $110,537 | 25.0% |
| 30% | $13.99 | $9.40 | $12.76 | $10.07 | $96,720 | 42.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 套餐组合的收入结构 。
常见错误与边界
- 把提前收款当收入。 年付首月收到 $61,261.20,但当期收入只有 $5,105.10;差额是递延负债。
- 只算折扣率,不算净经济成本。 15% 让利要先扣支付费节省和资金收益,LedgerLite 的净成本是 11.3%,不是 15%。
- 把不同 cohort 的递延余额直接相加。 年末 $22,000 是全部年付客户的快照,不等于某个新 cohort 的签约递延。
- 忘了退款和拒付。 年付一次性收款后仍可能退款;退款会同时减少现金和递延,不能只冲收入。
- 用月付流失直接套年付。 年付一年只有一次续约决策,月等效流失与月付不同;要比较的是年化后的流失。
- 忽略扩张收入。 本节 LTV 没有计入 NRR 102%;如果年付客户扩张明显,1.28% 的阈值可以放宽。
- 把保本销量增幅当销量预测。 25% 是 20% 折扣的保本线,不是折扣一定带来的增量。
- 忽略渠道分成。 Steam 30% 分成下,折扣的一部分由平台承担;官网 5% 支付费下,几乎全部由你承担。
- 忽略现金流的时间价值。 提前收款的价值取决于资金成本;8% 是一个教学参数,不是所有独立开发者的机会成本。
- 把年付折扣当万能留存工具。 如果产品没有形成使用习惯,年付只是把流失推迟到续约日,续约时仍会集中暴露。
年付折扣是「用价格换时间和留存」:现金提前到账,收入按月确认,折扣是否划算取决于流失下降能否覆盖净成本。6.3 把视角从单一价格移到多条产品线,讨论套餐组合如何改变加权客单价和收入结构。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。