以下情境为教学假设,人物与数字不代表经核实的真实案例。
坏账准备最容易混的地方,不是比例算错,而是把三个动作当成一件事。计提是期末对「可能收不回来」的估计;核销是确认某笔款「已经收不回来」后,把资产和准备同时减掉;转回是之后又收到钱,或者发现当初估多了,把多提的部分冲回去。三个动作都会让净应收发生变化,但只有计提和转回直接进入利润表,核销本身通常不产生新的费用。
三个动作,三张不同的表
先把三个动作的定义分开:
- 计提:期末按账龄或组合估计一个准备余额,差额记入坏账费用。应收总额不变,准备增加,净应收减少。
- 核销:某笔应收确认无法收回,把应收总额和准备余额同时减去同一金额。准备足够时,利润表不再记费用;净应收不变。
- 转回:核销之后又收回钱,或者准备提多了,把多提的部分冲回。现金或准备增加,利润表记转回收入或负费用。
把 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,280 | 0.5% | $56 |
| 31–90 天 | $600 | 5% | $30 |
| 90 天以上 | $120 | 28% | $34 |
| 合计 | $12,000 | 1.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 | $720 | 6% | 1% | $120 | $11,880 |
| Nord Studio | $11,264 | $901 | 8% | 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 催收升级与法律途径的成本收益 。
常见错误与边界
- 把计提当核销。 计提只是把净应收降到预计可收回的水平,应收总额和债权本身都还在;核销才是把资产从账上移除。
- 核销时再记一次费用。 准备足够时,核销不产生新费用;费用已经在计提时进了利润表。准备不足时,只补提差额,不是把核销额全部再费用化。
- 核销后把应收加回来。 收回已核销款应记现金和转回收入,不应恢复应收;恢复应收会让同一笔款有被收两次的风险。
- 把转回记成销售收入。 转回没有带来新客户或新交付,不能算进 MRR、ARR 或新签收入。
- 用总额应收忽略准备。 资产负债表按总额列 12,000 时,资产和权益都会比净额口径高 120;两个口径不能混用。
- 用一套准备率套所有客户。 LedgerLite 的订阅客户和 Nord 的单一大额服务客户,逾期率与集中度不同,准备率也不应相同。
- 忘记期初准备。 本期计提是「应提准备减期初余额」,不是「应提准备」本身;忽略期初会把计提重复计算。
- 把会计准备当税务扣除。 会计上计提的准备不一定在当期可税前扣除,税务口径要单独核对;本节只讲财务口径。
- 认为计提后就不能催收。 计提是估值动作,不是放弃债权;催收、停止服务和法律途径都仍然可用。
- 把案例的 1% 和 2% 当行业基准。 它们是教学假设下的案例值,不是统计调查结果;换一个客户结构,合理准备率会变。
本节的边界是「坏账准备的三步账务口径」:只覆盖按账龄计提、核销和转回,不展开税务扣除、法律追偿、组合模型的统计参数和审计调整。具体法律程序因司法辖区而异,本节不构成法律意见;催收与破产顺位分别在第 4.2 节和第 4.3 节展开。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。