《独立开发者的商业指南·合同、应收与风险》4.1 坏账准备:计提、核销与转回

坏账准备不是把应收一笔勾销。本节用 LedgerLite 的 12,000 应收与 Nord 的 11,264 单一大额应收,拆开按账龄计提、确认无法收回后的核销、以及后续收回的转回三步,说明准备是利润表费用、核销只是资产与准备同时减少,并用 TypeScript 把三种动作写成可测试的函数。

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

坏账准备最容易混的地方,不是比例算错,而是把三个动作当成一件事。计提是期末对「可能收不回来」的估计;核销是确认某笔款「已经收不回来」后,把资产和准备同时减掉;转回是之后又收到钱,或者发现当初估多了,把多提的部分冲回去。三个动作都会让净应收发生变化,但只有计提和转回直接进入利润表,核销本身通常不产生新的费用。

三个动作,三张不同的表

先把三个动作的定义分开:

  • 计提:期末按账龄或组合估计一个准备余额,差额记入坏账费用。应收总额不变,准备增加,净应收减少。
  • 核销:某笔应收确认无法收回,把应收总额和准备余额同时减去同一金额。准备足够时,利润表不再记费用;净应收不变。
  • 转回:核销之后又收回钱,或者准备提多了,把多提的部分冲回。现金或准备增加,利润表记转回收入或负费用。

把 LedgerLite 的 12,000 应收放在一张表里,三个动作的差别就很清楚:

动作触发时点典型分录利润表应收总额准备余额净应收
计提期末复核借:坏账费用;贷:坏账准备费用增加不变增加减少
核销确认无法收回借:坏账准备;贷:应收无(准备足够时)减少减少不变
转回收回已核销款或准备过多借:现金或坏账准备;贷:转回收入或坏账费用收入增加或费用减少不变视情况增加

这张表的关键在最后两列。计提让净应收减少,但应收总额不变;核销让应收总额和准备余额同时减少,净应收不变;转回让净应收增加,但应收总额也不变,因为钱已经收回来了,或者准备被冲减。

如果核销金额大于已提准备,差额要先补提。补提是费用,核销仍然只是资产与准备同时减少。所以「核销」和「费用」不是同义词:准备足够时,核销不碰利润表;准备不足时,先补提,再核销。

按账龄计提:LedgerLite 的 120 美元

LedgerLite 年末应收 12,000 美元,逾期 30 天以上占 6%,坏账准备率 1%。三个数乘起来:

逾期 30 天以上 = 12,000 × 6% = 720
坏账准备       = 12,000 × 1% = 120
净应收         = 12,000 − 120 = 11,880

1% 是案例给的组合准备率。真实做法通常按账龄分档,每档一个比例;案例没有给分档余额,下面的拆分是教学假设,只用来演示算法,不增加案例事实。

账龄余额计提比例(教学假设)准备
0–30 天$11,2800.5%$56
31–90 天$6005%$30
90 天以上$12028%$34
合计$12,0001.0% 混合$120

11,280 + 600 + 120 = 12,000;56 + 30 + 34 = 120。分档比例是教学假设,合计正好回到案例的 1% 组合准备率。90 天以上那 120 美元,就是下一节核销示例里的那笔 invoice。

计提的通用公式是:

应提准备 = Σ(各档余额 × 各档损失率)
本期计提 = 应提准备 − 期初准备余额

如果期初已经提了 20,本期只补 100;如果期初提了 140,本期就是 −20,也就是转回 20。负的计提不是会计花招,而是把上次估多的部分冲回利润表。

准备是估计,不是放弃债权。计提之后,客户仍然欠这笔钱,公司也仍然可以催收;准备只是把这项资产的账面价值降到「预计能收回」的水平。账龄表本身怎么生成,见 3.2 应收账龄表:从余额到风险分档 。

Nord 的单一大额应收:11,264 的 225

Nord Studio 年收入 154,320 美元,应收占 7.3%,所以应收约 11,264 美元;逾期 8%,准备 2%。三个数乘起来:

逾期 30 天以上 = 11,264 × 8% = 901
坏账准备       = 11,264 × 2% = 225
净应收         = 11,264 − 225 = 11,039

901.12 和 225.28 在正文里四舍五入到整数美元,代码里保留美分。两个案例放在一起:

案例应收逾期 30 天以上逾期率准备率准备净应收
LedgerLite$12,000$7206%1%$120$11,880
Nord Studio$11,264$9018%2%$225$11,039

LedgerLite 的准备率 1%,Nord 是 2%。差异不是随意的:Nord 是服务收入、Net 30、前 3 家客户占 48%,单笔金额大、回款慢;LedgerLite 是订阅、Net 15、260 家客户分散。准备率是判断,不是定律;案例给的是结果,不是推导过程。

Nord 的应收集中在少数客户,组合账龄法在这里退化成「单笔大额」判断:一笔 11,264 美元的应收,2% 的准备是 225 美元,但它对利润的潜在冲击远大于 260 家客户里任何一家的小额欠款。客户集中度本身在 7.2 客户集中度 里算过,这里只看它对坏账准备的影响。

Tidecast 的应收是 0,坏账准备也是 0;它的风险不在客户欠款,而在平台结算。平台在分成前先扣退款和费用,开发者看到的是净额;这部分风险更适合放在平台依赖与破产顺位里讨论,而不是坏账准备。

核销与转回:资产减少与后续收回

核销:资产减少,但不是第二次费用

假设 LedgerLite 的 90 天以上那笔 120 美元确认无法收回。期初准备正好是 120,核销 120:

时点应收总额准备余额净应收本期坏账费用
计提后$12,000$120$11,880$120
核销 120 后$11,880$0$11,880$0

核销后净应收仍是 11,880,总资产也没有因为核销再减少一次。费用在计提时已经进了利润表;核销只是把「应收」和「准备」这两个科目同时减掉,让表上不再挂着一笔已知收不回来的钱。

如果核销 360,而准备只有 120,差额 240 先补提:

情形准备余额核销额补提核销后应收核销后准备净应收
准备足够$120$120$0$11,880$0$11,880
准备不足$120$360$240$11,640$0$11,640

准备不足时,净应收从 11,880 降到 11,640,减少的 240 就是补提的费用。核销本身仍然没有「再记一次 360 的费用」;它只是把已经确认的损失从准备科目转到应收科目。

核销也不等于放弃债权。公司可以在账外继续追,之后收到钱,再做转回。核销只是让财务报表不再把一笔已知收不回的款当成资产。

转回:收回已核销的钱

接上面的例子:核销 120 之后,客户又还了 60。这 60 不是新收入,也不是应收减少,因为应收已经在核销时从账上移除了。分录是:

借:现金 60
贷:收回已核销坏账 60

利润表上记 60 的转回收入,应收总额仍是 11,880,准备仍是 0,净应收仍是 11,880。剩下的 60 仍在账外,未来再收到再转回。

另一种转回是准备提多了。假设下期复核只需要 80 的准备,而账上还有 120,就把 40 冲回,计提变成 −40。它和收回已核销款都增加利润,但来源不同:一个是现金真的回来了,一个是估计下调。

转回不能记成销售收入。它没有带来新的客户、新的订阅或新的交付;把它算进 MRR 或 ARR,会高估收入的可持续性。收入质量的口径见 4.2 收入质量 。

写成代码:bad-debt.ts

下面的 bad-debt.ts 把三个动作拆成三个函数:provisionByAging 按账龄档计算应提准备和本期计提,writeOff 处理核销与准备不足时的补提,recoverWrittenOff 处理核销后的收回。金额全部用整数美分,比率用基点;计提结果为负表示转回。

// file: src/contract/bad-debt.ts
// tier: L1
// verify: node --test test/contract/bad-debt.test.ts

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

export type AgingBucket = {
  label: string;
  balanceCents: Cents;
  lossRateBps: Bps;
};

export type BucketProvision = {
  label: string;
  balanceCents: Cents;
  lossRateBps: Bps;
  provisionCents: Cents;
};

export type ProvisionResult = {
  totalCents: Cents;
  requiredAllowanceCents: Cents;
  existingAllowanceCents: Cents;
  provisionExpenseCents: Cents;
  netReceivablesCents: Cents;
  byBucket: BucketProvision[];
};

export type WriteOffResult = {
  receivablesCents: Cents;
  allowanceCents: Cents;
  writeOffCents: Cents;
  additionalProvisionCents: Cents;
};

export type RecoveryResult = {
  recoveredCents: Cents;
  reversalIncomeCents: Cents;
  remainingWrittenOffCents: Cents;
};

function checkNonNegative(value: Cents, label: string): Result<true> {
  if (!Number.isInteger(value) || value < 0) {
    return fail(`${label} must be a non-negative integer amount in cents`);
  }
  return ok(true);
}

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

/**
 * Provision is an income-statement expense: it raises a contra-asset
 * allowance without removing the receivable. A negative expense is a
 * reversal and appears when the required allowance falls below the balance
 * already booked.
 */
export function provisionByAging(
  buckets: AgingBucket[],
  existingAllowanceCents: Cents = 0,
): Result<ProvisionResult> {
  if (buckets.length === 0) {
    return fail("at least one aging bucket is required");
  }
  const existing = checkNonNegative(existingAllowanceCents, "existing allowance");
  if (!existing.ok) {
    return existing;
  }
  const byBucket: BucketProvision[] = [];
  let totalCents = 0;
  let requiredAllowanceCents = 0;
  for (const bucket of buckets) {
    if (bucket.label.trim() === "") {
      return fail("bucket label cannot be empty");
    }
    const balance = checkNonNegative(bucket.balanceCents, `${bucket.label} balance`);
    if (!balance.ok) {
      return balance;
    }
    const rate = checkRate(bucket.lossRateBps, `${bucket.label} loss rate`);
    if (!rate.ok) {
      return rate;
    }
    const provisionCents = applyRate(bucket.balanceCents, bucket.lossRateBps);
    totalCents = add(totalCents, bucket.balanceCents);
    requiredAllowanceCents = add(requiredAllowanceCents, provisionCents);
    byBucket.push({
      label: bucket.label,
      balanceCents: bucket.balanceCents,
      lossRateBps: bucket.lossRateBps,
      provisionCents,
    });
  }
  return ok({
    totalCents,
    requiredAllowanceCents,
    existingAllowanceCents,
    provisionExpenseCents: sub(requiredAllowanceCents, existingAllowanceCents),
    netReceivablesCents: sub(totalCents, requiredAllowanceCents),
    byBucket,
  });
}

/**
 * Write-off is a balance-sheet event: receivables and the allowance fall by
 * the same amount. If the allowance is too small, the shortfall is booked as
 * an additional provision first, so profit takes the hit before the asset
 * disappears. With an adequate allowance, net receivables do not move.
 */
export function writeOff(input: {
  receivablesCents: Cents;
  allowanceCents: Cents;
  writeOffCents: Cents;
}): Result<WriteOffResult> {
  const receivables = checkNonNegative(input.receivablesCents, "receivables");
  if (!receivables.ok) {
    return receivables;
  }
  const allowance = checkNonNegative(input.allowanceCents, "allowance");
  if (!allowance.ok) {
    return allowance;
  }
  const amount = checkNonNegative(input.writeOffCents, "write-off amount");
  if (!amount.ok) {
    return amount;
  }
  if (input.writeOffCents === 0) {
    return fail("write-off amount must be greater than zero");
  }
  if (input.writeOffCents > input.receivablesCents) {
    return fail("write-off cannot exceed receivables");
  }
  const additionalProvisionCents = Math.max(
    0,
    sub(input.writeOffCents, input.allowanceCents),
  );
  const fundedAllowance = add(input.allowanceCents, additionalProvisionCents);
  return ok({
    receivablesCents: sub(input.receivablesCents, input.writeOffCents),
    allowanceCents: sub(fundedAllowance, input.writeOffCents),
    writeOffCents: input.writeOffCents,
    additionalProvisionCents,
  });
}

/**
 * A recovery after write-off is not a new receivable. Cash arrives and the
 * earlier write-off is reversed through income; the receivable stays off the
 * books so the same balance cannot be collected twice.
 */
export function recoverWrittenOff(input: {
  writtenOffCents: Cents;
  recoveredCents: Cents;
}): Result<RecoveryResult> {
  const writtenOff = checkNonNegative(input.writtenOffCents, "written-off amount");
  if (!writtenOff.ok) {
    return writtenOff;
  }
  const recovered = checkNonNegative(input.recoveredCents, "recovered amount");
  if (!recovered.ok) {
    return recovered;
  }
  if (input.recoveredCents === 0) {
    return fail("recovered amount must be greater than zero");
  }
  if (input.recoveredCents > input.writtenOffCents) {
    return fail("recovery cannot exceed the written-off amount");
  }
  return ok({
    recoveredCents: input.recoveredCents,
    reversalIncomeCents: input.recoveredCents,
    remainingWrittenOffCents: sub(input.writtenOffCents, input.recoveredCents),
  });
}

provisionByAging 的输入是账龄档数组和期初准备。LedgerLite 的三档是 1,128,000、60,000、12,000 美分,损失率 50、500、2,800 个基点;输出应提准备 12,000 美分,净应收 1,188,000 美分。Nord 只有一档 1,126,400 美分、200 个基点,输出准备 22,528 美分,净应收 1,103,872 美分。

writeOff 在准备足够时返回的 additionalProvisionCents 是 0;准备不足时先补提,再把准备和应收同时减掉。recoverWrittenOff 不把应收加回来,只返回转回收入和剩余已核销金额,避免同一笔款被收两次。

代码不处理税、汇兑、折现和组合模型的统计参数;它只覆盖本节的三步口径。催收节奏见 3.3 开票、对账与催收节奏 。

小结

  • 计提是利润表费用:LedgerLite 按 1% 提 120,净应收从 12,000 降到 11,880;Nord 按 2% 提 225,净应收降到 11,039。
  • 核销是资产与准备同时减少:准备足够时,核销 120 后净应收仍是 11,880,利润表不再记费用;准备不足时,差额先补提。
  • 转回是收回已核销款或冲回多提准备:收回 60 记 60 的转回收入,不把应收加回来;准备多提 40 记 −40 的计提。
  • 三者的分界不是「有没有现金」,而是「利润表有没有新费用、应收总额有没有变、准备余额有没有变」。
  • 准备率是判断:LedgerLite 的 1% 与 Nord 的 2% 来自客户结构、账期和集中度,不是行业基准。
  • 核销不等于放弃债权;账外仍可追,收回时再转回。

阅读导航:上一节:3.3 开票、对账与催收节奏 · 下一节:4.2 催收升级与法律途径的成本收益 。

常见错误与边界

  1. 把计提当核销。 计提只是把净应收降到预计可收回的水平,应收总额和债权本身都还在;核销才是把资产从账上移除。
  2. 核销时再记一次费用。 准备足够时,核销不产生新费用;费用已经在计提时进了利润表。准备不足时,只补提差额,不是把核销额全部再费用化。
  3. 核销后把应收加回来。 收回已核销款应记现金和转回收入,不应恢复应收;恢复应收会让同一笔款有被收两次的风险。
  4. 把转回记成销售收入。 转回没有带来新客户或新交付,不能算进 MRR、ARR 或新签收入。
  5. 用总额应收忽略准备。 资产负债表按总额列 12,000 时,资产和权益都会比净额口径高 120;两个口径不能混用。
  6. 用一套准备率套所有客户。 LedgerLite 的订阅客户和 Nord 的单一大额服务客户,逾期率与集中度不同,准备率也不应相同。
  7. 忘记期初准备。 本期计提是「应提准备减期初余额」,不是「应提准备」本身;忽略期初会把计提重复计算。
  8. 把会计准备当税务扣除。 会计上计提的准备不一定在当期可税前扣除,税务口径要单独核对;本节只讲财务口径。
  9. 认为计提后就不能催收。 计提是估值动作,不是放弃债权;催收、停止服务和法律途径都仍然可用。
  10. 把案例的 1% 和 2% 当行业基准。 它们是教学假设下的案例值,不是统计调查结果;换一个客户结构,合理准备率会变。

本节的边界是「坏账准备的三步账务口径」:只覆盖按账龄计提、核销和转回,不展开税务扣除、法律追偿、组合模型的统计参数和审计调整。具体法律程序因司法辖区而异,本节不构成法律意见;催收与破产顺位分别在第 4.2 节和第 4.3 节展开。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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