以下情境为教学假设,人物与数字不代表经核实的真实案例。
LedgerLite 的年付客户在签约当天就把一整年的钱打进来。银行账户增加了,但会计上的收入没有增加;这笔钱先是一笔负债,然后随着服务逐月交付,才一点一点变成收入。很多独立开发者在这里第一次遇到「账上有钱、利润表没钱」的困惑。
这一节只回答一个问题:一笔钱什么时候可以算作收入。答案取决于交付义务什么时候完成,而不是钱什么时候到账。LedgerLite 的年付订阅和 Tidecast 的买断游戏,正好是两种确认时点的极端;Nord Studio 的咨询与订阅混合,则说明同一家公司可以同时存在两种节奏。
先把三个案例的收入确认结构放在一张表里:
| 项 | LedgerLite | Tidecast | Nord Studio |
|---|---|---|---|
| 主要收入 | 订阅,年付 30%、月付 70% | 买断制游戏 | 咨询 + 产品订阅 |
| 收款节奏 | 年付一次性预收,月付按月 | 购买时一次性收款 | 咨询按项目/账期,订阅按月 |
| 确认节奏 | 按服务月份分摊 | 交付时点确认 | 咨询按履约进度,订阅按月 |
| 退款处理 | 冲减递延与已确认收入 | 按 4% 计提退款准备 | 按合同条款处理 |
| 年末递延收入 | $22,000 | 无 | 订阅部分按月递延 |
预收现金不是收入
LedgerLite 有 260 家付费客户,ARPA 是 $77/月,MRR 是 $20,020,ARR 是 $240,240。事实表给出的年付比例是 30%,也就是:
年付客户数 = 260 × 30% = 78 家
月付客户数 = 260 × 70% = 182 家
年付有 15% 折扣。对一家年付客户来说,标价是 12 × $77 = $924,折后一次性支付:
年付现金 = 924 × (1 − 15%) = $785.40
月确认收入 = 785.40 ÷ 12 = $65.45
签约当天,LedgerLite 收到 $785.40 现金,但交付义务还有 12 个月。会计上不能把这 $785.40 全部记成收入,只能先记一笔递延收入负债;此后每交付一个月,就把 $65.45 从负债转成收入。
用一家年付客户走一遍:
| 时点 | 收到现金 | 当月确认收入 | 递延收入余额 |
|---|---|---|---|
| 签约当天 | $785.40 | $0.00 | $785.40 |
| 第 1 个月末 | $0.00 | $65.45 | $719.95 |
| 第 3 个月末 | $0.00 | $65.45 | $589.05 |
| 第 6 个月末 | $0.00 | $65.45 | $392.70 |
| 第 12 个月末 | $0.00 | $65.45 | $0.00 |
这张表说明两件事。第一,现金只在签约当天发生一次,收入却分 12 次发生。第二,递延收入余额不是「还没赚到的利润」,而是一项服务义务:客户已经付钱,LedgerLite 还欠 12 个月的服务。
如果只看银行账户,第 1 个月会以为收入是 $785.40;如果只看利润表,第 1 个月只看到 $65.45。两个数字都对,但回答的是不同问题。现金看流动性,收入看履约进度。
递延收入的滚动
把 78 家年付客户放在一起,年末递延收入余额是事实表给出的 $22,000。这个余额可以用一个恒等式解释:
期末递延收入 = 期初递延收入 + 当年新增预收 − 当年确认收入
现金流量表里还有一项「递延收入增加 +$8,000」,它等于:
递延收入增加 = 期末递延 − 期初递延 = 22,000 − 14,000 = $8,000
所以期初递延是 $22,000 − $8,000 = $14,000。这个 $14,000 是从事实表的两个数推出来的,不是新的事实。
假设年末递延全部来自年付合同(月付按月计费,不形成跨月递延),年付客户平均还剩多少个月没有交付?用余额反推:
平均剩余月数 = 22,000 ÷ (78 × 65.45) = 22,000 ÷ 5,105.10 ≈ 4.31 个月
4.31 个月是为对齐 $22,000 余额采用的教学假设,不是事实表原值。反过来验算:78 × 65.45 × 4.31 = $22,002.98,四舍五入到整数美元就是 $22,000。这说明年末递延余额大约相当于四个多月的年付服务义务。
递延收入占 ARR 的比例是:
递延收入 / ARR = 22,000 ÷ 240,240 = 9.2%
9.2% 不算高,因为 LedgerLite 只有 30% 的客户年付,而且年付客户的签约时间分散。如果年付比例升到 60%,同样的折扣下递延余额会显著变大,现金会更早进来,但收入不会提前。这是年付折扣真正的代价:用 15% 的价格换现金流,不是换收入。
退款与一次性收入的确认
年付客户在第 3 个月申请退款,处理要分三块:
| 项 | 金额 | 说明 |
|---|---|---|
| 退还现金 | $785.40 | 全额退回 |
| 冲减已确认收入 | $196.35 | 3 × $65.45 |
| 释放剩余递延 | $589.05 | 9 × $65.45 |
退款不是「少确认一笔新收入」,而是把已经确认的 $196.35 反向冲回,同时把还没确认的 $589.05 从递延负债里释放。退款之后,这份合同累计确认收入回到 $0,递延余额也回到 $0。
Tidecast 的确认节奏完全不同。它卖的是买断制游戏,标价 $19.99,年销 9,600 份,标价收入 $191,904。收入在交付(购买)时点确认,但退款同样要处理:
退款准备 = 191,904 × 4% = $7,676(精确值 $7,676.16)
退款后收入 = 191,904 − 7,676 = $184,228
接下来才是渠道成本。Steam 分成 30%,按退款后的 Steam 净销售额计算:
Steam 退款后收入 = 153,523 − 6,141 = $147,382
Steam 平台分成 = 147,382 × 30% = $44,215
官网支付费 = (38,381 − 1,535) × 5% = $1,842
最终净得 $138,171。这个数字已经在事实表里,上面的拆解只是把「4% 退款、30% 分成、5% 支付费」三个口径落到具体计算上。注意平台分成和支付费的分母都是退款后的金额,不是标价收入;如果拿标价收入直接乘 30%,会高估平台成本。
一次性收入没有递延问题,但有退款准备问题。Tidecast 的 4% 不是「已经发生的退款」,而是对全年退款的估计;实际退款可能集中在发售后两周,也可能因为促销延后。确认时点越靠前,退款估计越重要。
确认时点如何改变三张表
把 LedgerLite 的年度数字放回三张表,可以看到同一个 $8,000 递延增加如何影响三处:
| 报表 | 项目 | 金额 | 与确认时点的关系 |
|---|---|---|---|
| 利润表 | 收入 | $240,240 | 只含已履约部分 |
| 利润表 | 净利润 | $58,299 | 收入减成本、费用、税 |
| 资产负债表 | 递延收入 | $22,000 | 尚未履约的服务义务 |
| 现金流量表 | 递延收入增加 | +$8,000 | 现金领先于收入的部分 |
| 现金流量表 | 经营现金流 | $64,299 | 净利润 + 8,000 − 应收增加 2,000 |
经营现金流的推导是:
经营现金流 = 净利润 + 递延收入增加 − 应收增加
= 58,299 + 8,000 − 2,000
= $64,299
利润表里的 $58,299 和现金流里的 $64,299 相差 $6,000,其中 $8,000 是客户提前付的钱,−$2,000 是应收占用的钱。如果不理解递延收入,就会把 $64,299 当成「利润比报表更好」,或者把 $58,299 当成「现金比利润更差」。两者都不是判断,只是确认时点造成的时间差。
确认时点还会影响估值。买方看 ARR 时,通常会问「这个 ARR 里有多少已经预收、多少还在应收」;预收多的公司现金更好,但递延负债也更高。这个问题在第 4.3 节和第 11 章会继续展开。订阅状态机的实现细节不在本节展开,工程侧见 订阅状态机 。
把确认写成代码
下面的 revenue.ts 把本节的口径固化成五个函数:prepaidCash 算折后预收,recognitionSchedule 生成按月确认计划,deferredBalance 汇总多份合同的递延余额,refundAdjustment 处理退款冲减,oneTimeRefund 处理一次性收入的退款准备。所有金额用整数美分,折扣用基点,非法输入返回 Result 错误。
// file: src/fin/revenue.ts
// tier: L1
// verify: node --test test/fin/revenue.test.ts
import { type Cents, sub, sum } from "../core/money.ts";
import { type Bps, applyRate } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";
export type PrepaidContract = {
monthlyCents: Cents;
months: number;
discountBps: Bps;
};
export type ContractProgress = PrepaidContract & {
elapsedMonths: number;
};
export type MonthlyEntry = {
month: number;
recognizedCents: Cents;
deferredCents: Cents;
};
export type RefundAdjustment = {
refundCents: Cents;
reversedRevenueCents: Cents;
releasedDeferredCents: Cents;
};
export type OneTimeRefund = {
grossCents: Cents;
refundCents: Cents;
netCents: Cents;
};
function checkContract(contract: PrepaidContract): string | null {
if (!Number.isInteger(contract.months) || contract.months <= 0) {
return "months must be a positive integer";
}
if (
!Number.isInteger(contract.monthlyCents) ||
contract.monthlyCents < 0
) {
return "monthly amount must be a non-negative integer";
}
if (
!Number.isInteger(contract.discountBps) ||
contract.discountBps < 0 ||
contract.discountBps >= 10000
) {
return "discount must be between 0 and 9999 bps";
}
return null;
}
function splitEvenly(total: Cents, months: number): Cents[] {
const base = Math.floor(total / months);
const remainder = total - base * months;
return Array.from(
{ length: months },
(_, index) => base + (index < remainder ? 1 : 0),
);
}
/** Cash collected up front for a discounted prepaid contract. */
export function prepaidCash(contract: PrepaidContract): Result<Cents> {
const error = checkContract(contract);
if (error) {
return fail(error);
}
return ok(
applyRate(
contract.monthlyCents * contract.months,
10000 - contract.discountBps,
),
);
}
/**
* Monthly recognition schedule. The split is rounded in cents, so the first
* `total % months` entries carry one extra cent and the sum is exact.
*/
export function recognitionSchedule(
contract: PrepaidContract,
): Result<MonthlyEntry[]> {
const cash = prepaidCash(contract);
if (!cash.ok) {
return cash;
}
const parts = splitEvenly(cash.value, contract.months);
let deferred = cash.value;
return ok(
parts.map((recognized, index) => {
deferred = sub(deferred, recognized);
return {
month: index + 1,
recognizedCents: recognized,
deferredCents: deferred,
};
}),
);
}
/** Remaining deferred balance for a set of contracts at their current progress. */
export function deferredBalance(
contracts: ContractProgress[],
): Result<Cents> {
if (contracts.length === 0) {
return fail("at least one contract is required");
}
const balances: Cents[] = [];
for (const contract of contracts) {
const error = checkContract(contract);
if (error) {
return fail(error);
}
if (
!Number.isInteger(contract.elapsedMonths) ||
contract.elapsedMonths < 0 ||
contract.elapsedMonths > contract.months
) {
return fail("elapsed months out of range");
}
const cash = prepaidCash(contract);
if (!cash.ok) {
return cash;
}
const schedule = recognitionSchedule(contract);
if (!schedule.ok) {
return schedule;
}
balances.push(
contract.elapsedMonths === 0
? cash.value
: schedule.value[contract.elapsedMonths - 1].deferredCents,
);
}
return ok(sum(balances));
}
/**
* A refund after `refundAfterMonths` returns all cash, reverses revenue already
* recognized, and releases whatever deferred balance remains.
*/
export function refundAdjustment(
contract: PrepaidContract,
refundAfterMonths: number,
): Result<RefundAdjustment> {
const error = checkContract(contract);
if (error) {
return fail(error);
}
if (
!Number.isInteger(refundAfterMonths) ||
refundAfterMonths < 0 ||
refundAfterMonths > contract.months
) {
return fail("refund month out of range");
}
const cash = prepaidCash(contract);
if (!cash.ok) {
return cash;
}
const schedule = recognitionSchedule(contract);
if (!schedule.ok) {
return schedule;
}
const reversedRevenueCents = sum(
schedule.value
.slice(0, refundAfterMonths)
.map((entry) => entry.recognizedCents),
);
return ok({
refundCents: cash.value,
reversedRevenueCents,
releasedDeferredCents: sub(cash.value, reversedRevenueCents),
});
}
/** Point-in-time sale with an estimated refund provision. */
export function oneTimeRefund(
grossCents: Cents,
refundRateBps: Bps,
): Result<OneTimeRefund> {
if (!Number.isInteger(grossCents) || grossCents < 0) {
return fail("gross amount must be a non-negative integer");
}
if (
!Number.isInteger(refundRateBps) ||
refundRateBps < 0 ||
refundRateBps > 10000
) {
return fail("refund rate must be between 0 and 10000 bps");
}
const refundCents = applyRate(grossCents, refundRateBps);
return ok({
grossCents,
refundCents,
netCents: sub(grossCents, refundCents),
});
}
代码里有三个细节。
第一,recognitionSchedule 用「先整除、再把余数分给前几个月」的方式拆分明细,保证 12 个月确认额之和精确等于预收现金。LedgerLite 的 $785.40 正好能被 12 整除,所以每月都是 $65.45;不能整除时,前几个月的金额会多 1 美分,但总额不变。
第二,deferredBalance 接收的是「合同 + 已过月数」,而不是一个全局的平均值。真实账本里每份合同的签约日不同,递延余额必须逐份合同算完再汇总,不能用「平均剩余月数 × 客户数」代替。本节用 4.31 个月只是为了对齐事实表的 $22,000,不是代码的输入口径。
第三,退款函数同时返回三个数:退还现金、冲减已确认收入、释放剩余递延。三者相加的关系是「退还现金 = 冲减收入 + 释放递延」,这样无论退款发生在第 0 个月还是第 12 个月,账都能对上。
用 LedgerLite 的年付合同调用,结果是:预收 78,540 美分($785.40),每月确认 6,545 美分($65.45),第 3 个月末递延余额 58,905 美分($589.05),第 3 个月退款冲减收入 19,635 美分($196.35)。这些数字与上面的表一致。
小结
- 预收是负债,不是收入;收入按履约进度确认,不按收款时点确认。
- LedgerLite 年付客户 78 家,单份年付现金 $785.40,月确认 $65.45,年末递延余额 $22,000。
- 递延收入增加 $8,000 = 期末 $22,000 − 期初 $14,000;它解释净利润与经营现金流为什么相差 $6,000。
- 退款要同时冲减已确认收入、释放剩余递延;Tidecast 的一次性收入则按 4% 计提退款准备。
- 平台分成和支付费的分母是退款后金额,不是标价收入。
- 确认时点只改变收入与现金的时间分布,不改变长期总额;但它会改变每一期的利润、税和估值口径。
阅读导航:上一节:3.3 资产负债表:应收、递延收入与权益 · 下一节:4.2 收入质量:集中度、续费、退款 。
常见错误与边界
- 把到账现金当收入。 年付 $785.40 到账当天,收入仍然是 $0;把预收记成收入会同时高估利润和税基。
- 把递延收入当利润。 递延收入是服务义务,不是已经赚到的钱;它最终会转成收入,但前提是服务真的交付。
- 用平均剩余月数代替逐份合同。 4.31 个月只用于对齐 $22,000 的年末余额;真实账本必须按每份合同的签约日和已过月数计算。
- 退款只冲现金、不冲收入。 退款同时影响现金、已确认收入和递延负债;只改银行账户会让账实不符。
- 平台分成按标价收入算。 Steam 的 30% 按退款后净销售额计算,用 $153,523 × 30% 会高估成本约 $1,842。
- 忽略一次性收入的退款估计。 Tidecast 的 4% 是全年估计,不是已发生退款;不计提准备会高估当期收入。
- 把递延增加当成「额外利润」。 它只是现金领先于收入的部分,未来会通过确认收入慢慢回到利润表,不是白得的收益。
- 用 ARR 代替已确认收入报税。 ARR 是年度经常性收入的口径,税务通常看已确认收入;两者混用会带来申报风险,具体规则需按当期法规核对。
- 忽略年付折扣的代价。 15% 折扣换来的是一年现金,不是一年收入;折扣率越高,递延余额越大,现金越好,但单位经济越薄。
确认时点是收入质量的第一道门:同样一笔钱,确认得太早会高估当期经营,确认得太晚会让现金与利润脱节。下一节把视角从「什么时候确认」转到「确认之后的收入有多可靠」,用集中度、续费、退款和经常性占比四个分项拆解收入质量。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。