《独立开发者的商业指南·收入与财务工程》4.1 预收、递延与确认时点

预收不是收入。本节用 LedgerLite 的年付合同拆解预收现金、递延收入余额与按月确认的完整链路,给出退款发生时的冲减口径,并以 Tidecast 的一次性买断收入作对照,说明确认时点如何同时改变利润表、资产负债表与经营现金流,以及为什么「收到钱」和「赚到钱」必须分开看。

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

LedgerLite 的年付客户在签约当天就把一整年的钱打进来。银行账户增加了,但会计上的收入没有增加;这笔钱先是一笔负债,然后随着服务逐月交付,才一点一点变成收入。很多独立开发者在这里第一次遇到「账上有钱、利润表没钱」的困惑。

这一节只回答一个问题:一笔钱什么时候可以算作收入。答案取决于交付义务什么时候完成,而不是钱什么时候到账。LedgerLite 的年付订阅和 Tidecast 的买断游戏,正好是两种确认时点的极端;Nord Studio 的咨询与订阅混合,则说明同一家公司可以同时存在两种节奏。

先把三个案例的收入确认结构放在一张表里:

项LedgerLiteTidecastNord 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.353 × $65.45
释放剩余递延$589.059 × $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 收入质量:集中度、续费、退款 。

常见错误与边界

  1. 把到账现金当收入。 年付 $785.40 到账当天,收入仍然是 $0;把预收记成收入会同时高估利润和税基。
  2. 把递延收入当利润。 递延收入是服务义务,不是已经赚到的钱;它最终会转成收入,但前提是服务真的交付。
  3. 用平均剩余月数代替逐份合同。 4.31 个月只用于对齐 $22,000 的年末余额;真实账本必须按每份合同的签约日和已过月数计算。
  4. 退款只冲现金、不冲收入。 退款同时影响现金、已确认收入和递延负债;只改银行账户会让账实不符。
  5. 平台分成按标价收入算。 Steam 的 30% 按退款后净销售额计算,用 $153,523 × 30% 会高估成本约 $1,842。
  6. 忽略一次性收入的退款估计。 Tidecast 的 4% 是全年估计,不是已发生退款;不计提准备会高估当期收入。
  7. 把递延增加当成「额外利润」。 它只是现金领先于收入的部分,未来会通过确认收入慢慢回到利润表,不是白得的收益。
  8. 用 ARR 代替已确认收入报税。 ARR 是年度经常性收入的口径,税务通常看已确认收入;两者混用会带来申报风险,具体规则需按当期法规核对。
  9. 忽略年付折扣的代价。 15% 折扣换来的是一年现金,不是一年收入;折扣率越高,递延余额越大,现金越好,但单位经济越薄。
    确认时点是收入质量的第一道门:同样一笔钱,确认得太早会高估当期经营,确认得太晚会让现金与利润脱节。下一节把视角从「什么时候确认」转到「确认之后的收入有多可靠」,用集中度、续费、退款和经常性占比四个分项拆解收入质量。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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