《独立开发者的商业指南·合同、应收与风险》3.2 应收账龄表:从余额到风险分档

应收余额只有一个数,账龄表把它拆成 0–30、31–60、61–90、90+ 四段。本节用 LedgerLite 的 12,000 美元与 Nord Studio 的 11,264 美元,按 1%/5%/20%/50% 的教学准备率逐档计提,比较分档口径与统一比例口径的差额,并说明为什么 90+ 档的每一美元最贵。

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

3.1 节把应收余额换算成了现金占用天数。LedgerLite 的 12,000 美元应收是 18.2 天收入,Nord Studio 的 11,264 美元是 26.6 天收入。两个数字都只回答“占用了多少”,没有回答“这些钱有多老”。同样是一万美元应收,全部在 30 天内和一半超过 90 天,现金占用一样,风险完全不一样。

账龄表就是把余额按年龄拆开。它不改变应收总额,只改变你看待总额的方式。这一节先建立四个账龄档和每档的准备比例,再用两个案例算出分档准备金额,最后解释为什么“余额乘一个统一比例”会系统性地低估风险。

余额只有一个数,账龄是一张结构图

应收账龄表通常按 30 天一段切分:0–30 天、31–60 天、61–90 天、90 天以上。每一档记录的是“这个年龄区间里还有多少应收”,而不是“这个区间里发生了多少收入”。所有档位相加必须等于应收总额,否则表就漏了钱。

档位含义常见状态本节准备率(教学假设)
0–30 天未到期或刚到期正常账期内1%
31–60 天逾期 1 个月左右可能只是付款流程慢5%
61–90 天逾期 2 个月需要主动催收20%
90 天以上逾期 3 个月以上争议、失联或财务困难50%

四个准备率是本节的教学假设,不是会计准则,也不是任何行业的统计坏账率。它们的用途是建立一条可复算的曲线:账龄越老,每一美元的准备越高。真实比例要按客户结构、历史回收率和合同条款调整;调整时,整张表一起重算。

账龄表和应收余额的关系,可以写成三个公式:

逾期金额 = 应收余额 × 逾期比例

某档金额 = 逾期金额 × 该档在逾期中的占比

坏账准备 = Σ(某档金额 × 该档准备率)

关键在第二行。只有应收余额和逾期比例,还算不出准备;必须知道逾期金额在 31–60、61–90、90+ 三档之间怎么分布。这个分布就是账龄表真正多出来的信息。

两张账龄表:同样的余额,不同的年龄

LedgerLite 的应收是 12,000 美元,逾期 30 天以上占 6%,即 720 美元。Nord Studio 的应收是 11,264 美元,逾期占 8%,即 901.12 美元。两家都没有给出逾期金额在三档之间的拆分,本节采用一个统一的教学假设:逾期金额按 60%、25%、15% 分到 31–60、61–90、90+。依据是多数逾期会在两个月内收回,90 天以上只是尾部;这个拆分不是案例事实,换一个拆分,准备金额会变。

LedgerLite 的账龄表如下。

档位金额占应收准备率准备金额
0–30 天$11,280.0094.0%1%$112.80
31–60 天$432.003.6%5%$21.60
61–90 天$180.001.5%20%$36.00
90 天以上$108.000.9%50%$54.00
合计$12,000.00100%—$224.40

Nord Studio 的账龄表如下。

档位金额占应收准备率准备金额
0–30 天$10,362.8892.0%1%$103.63
31–60 天$540.674.8%5%$27.03
61–90 天$225.282.0%20%$45.06
90 天以上$135.171.2%50%$67.59
合计$11,264.00100%—$243.31

两家的应收只差 736 美元,准备金额却差 18.91 美元,准备率分别是 1.87% 和 2.16%。差额不是来自余额,而是来自年龄结构:Nord 的逾期比例更高,90+ 档的金额更大。

更有意思的是 90+ 档的“以小博大”。LedgerLite 的 90+ 只占应收的 0.9%,却贡献了 54.00 ÷ 224.40 = 24.1% 的准备;Nord 的 90+ 占 1.2%,贡献了 67.59 ÷ 243.31 = 27.8% 的准备。余额上看起来是零头,准备上却是四分之一。这就是账龄表要解决的问题:余额告诉你规模,账龄告诉你哪一部分在吃掉利润。

统一比例为什么会低估风险

如果只看案例事实里的“坏账准备 1%”和“2%”,会得到两个很整齐的数字:LedgerLite 12,000 × 1% = 120 美元,Nord 11,264 × 2% = 225.28 美元。分档计算的结果是 224.40 美元和 243.31 美元,分别高出 104.40 美元和 18.03 美元。

口径LedgerLiteNord Studio差额来源
统一比例$120.00(1%)$225.28(2%)把所有应收视为同质
账龄分档$224.40(1.87%)$243.31(2.16%)90+ 档按 50% 计提
差额+$104.40+$18.03逾期尾部

统一比例不是错,它是一种简化。问题在于它隐含了一个假设:所有应收的损失概率相同。账龄表显示这个假设不成立。LedgerLite 的 90+ 只有 108 美元,按 50% 计提是 54 美元;如果按 1% 统一计提,这 108 美元只准备 1.08 美元,相差近 50 倍。余额越小,统一比例越容易把尾部风险抹平。

反过来,统一比例也可能高估。如果一家公司的应收全部在 0–30 天,90+ 为零,那么 1% 的统一比例和分档结果完全一致;如果 0–30 档的实际损失率低于 1%,统一比例还会略微高估。账龄表的价值不是永远算出更大的数字,而是让准备金额跟着年龄结构走,而不是跟着一个拍脑袋的平均数走。

还有一层容易被忽略:准备金额是流动的。这个月为 90+ 计提的 54 美元,如果下个月客户付清,准备要反向冲回,利润表上会出现一笔非现金收益;反过来,一笔 0–30 的应收如果突然进入争议,下个月可能直接跳到 61–90,准备率从 1% 变成 20%。账龄表的每一格都在移动,准备不是一次算完的静态数字,而是跟着格子的移动每月重算。只看年度合计,会错过这种移动。

因此,账龄表的月度对比比单月快照更有信息量。单月表告诉你“现在有多老”,连续三个月的表告诉你“正在变老还是正在变好”;后者才是准备是否足够的依据。

这里要区分三个概念。逾期比例是“有多少钱晚于约定日期”,案例里的 6% 和 8% 属于这一类;准备率是“每一档预计收不回来多少”,1%/5%/20%/50% 属于这一类;核销是“确认某笔钱再也收不回来,把它从应收里拿掉”。逾期不等于坏账,准备也不等于核销。核销的会计处理与追回后的反转,见 4.1 坏账准备:计提、核销与转回 ;本节只处理准备的计算。

读账龄表的四个比率

一张账龄表不需要很多指标,四个比率就能看出结构。

比率公式LedgerLiteNord Studio读法
当前占比0–30 ÷ 应收94.0%92.0%越高越健康
逾期占比31+ ÷ 应收6.0%8.0%案例事实
90+ 占比90+ ÷ 应收0.9%1.2%尾部有多重
准备率准备 ÷ 应收1.87%2.16%年龄结构的综合价格

第一个比率看整体健康度。LedgerLite 的 94% 在 30 天内,Nord 的 92%,两家都不算差。第二个比率是案例已经给出的逾期率,LedgerLite 6%,Nord 8%。第三个比率把尾部单独拎出来:0.9% 和 1.2% 看起来都很小,但乘上 50% 的准备率之后,就是 24% 和 28% 的准备贡献。

第四个比率是准备率,它把前三个比率合成一个数。LedgerLite 1.87%,Nord 2.16%。如果只看逾期率,Nord 比 LedgerLite 高 2 个百分点;如果只看准备率,Nord 比 LedgerLite 高 0.29 个百分点。差额被当前档的 1% 稀释了:Nord 的应收里有 92% 是当前的,这部分只按 1% 计提,把整体准备率拉低。

这也解释了为什么账龄表要按月更新。客户今天还在 0–30 档,下个月可能进入 31–60 档;一笔 90+ 的应收收回后,准备要反转,整体准备率会下降。账龄表是快照,不是永久标签。按月滚动,才能看到“逾期在往哪个方向走”,而不是只知道“这个月有多少逾期”。

把账龄表写成代码

下面的 aging.ts 把账龄分档和准备计算固化成一条链路:输入应收余额、逾期比例和逾期拆分,输出四档金额、每档准备、总准备、准备率,以及准备对逾期的覆盖率。金额用整数美分,比例用基点,任何一档的金额加不回去都会被拒绝。

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

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

export type AgingBucket = "0-30" | "31-60" | "61-90" | "90+";

export const BUCKET_ORDER: AgingBucket[] = ["0-30", "31-60", "61-90", "90+"];

/** Teaching assumption, not an accounting standard or observed default rate. */
export const PROVISION_RATES: Record<AgingBucket, Bps> = {
  "0-30": 100,
  "31-60": 500,
  "61-90": 2000,
  "90+": 5000,
};

export type AgingRow = {
  bucket: AgingBucket;
  amount: Cents;
  rateBps: Bps;
  provision: Cents;
};

export type AgingReport = {
  receivables: Cents;
  current: Cents;
  overdue: Cents;
  rows: AgingRow[];
  provision: Cents;
  provisionRateBps: Bps;
  coverageOfOverdueBps: Bps;
};

export type AgingInput = {
  receivables: Cents;
  overdueBps: Bps;
  /** Split of overdue across 31-60, 61-90 and 90+, in bps. */
  overdueSplitBps?: [Bps, Bps, Bps];
};

export const DEFAULT_OVERDUE_SPLIT: [Bps, Bps, Bps] = [6000, 2500, 1500];

/**
 * An aging table is the age structure of the balance, not the balance itself.
 * The overdue amount is split across three buckets, and the 90+ bucket takes
 * the rounding remainder so the rows always add back to the total.
 */
export function buildAging(input: AgingInput): Result<AgingReport> {
  if (!Number.isInteger(input.receivables) || input.receivables < 0) {
    return fail("receivables must be a non-negative integer");
  }
  if (
    !Number.isInteger(input.overdueBps) ||
    input.overdueBps < 0 ||
    input.overdueBps > 10000
  ) {
    return fail("overdue share must be an integer between zero and 10000 bps");
  }
  const split = input.overdueSplitBps ?? DEFAULT_OVERDUE_SPLIT;
  if (
    split.length !== 3 ||
    split.some((part) => !Number.isInteger(part) || part < 0) ||
    split[0] + split[1] + split[2] !== 10000
  ) {
    return fail("overdue split must be three non-negative bps summing to 10000");
  }
  const overdue = applyRate(input.receivables, input.overdueBps);
  const current = input.receivables - overdue;
  const first = applyRate(overdue, split[0]);
  const second = applyRate(overdue, split[1]);
  const third = overdue - first - second;
  const amounts: Record<AgingBucket, Cents> = {
    "0-30": current,
    "31-60": first,
    "61-90": second,
    "90+": third,
  };
  const rows: AgingRow[] = BUCKET_ORDER.map((bucket) => ({
    bucket,
    amount: amounts[bucket],
    rateBps: PROVISION_RATES[bucket],
    provision: applyRate(amounts[bucket], PROVISION_RATES[bucket]),
  }));
  const provision = rows.reduce((total, row) => total + row.provision, 0);
  return ok({
    receivables: input.receivables,
    current,
    overdue,
    rows,
    provision,
    provisionRateBps: divide(provision, input.receivables),
    coverageOfOverdueBps: divide(provision, overdue),
  });
}

把 LedgerLite 的 1,200,000 美分和 600 基点代入,得到逾期 72,000 美分,四档金额 1,128,000、43,200、18,000、10,800 美分,四档准备 11,280、2,160、3,600、5,400 美分,总准备 22,440 美分,准备率 187 基点(1.87%)。Nord 的 1,126,400 美分和 800 基点得到逾期 90,112 美分,四档金额 1,036,288、54,067、22,528、13,517 美分,四档准备 10,363、2,703、4,506、6,759 美分,总准备 24,331 美分,准备率 216 基点(2.16%)。这些数字与上表逐行对应,只是单位从美元换成了美分。

代码里有三个刻意的设计。第一,90+ 档取舍入余数,而不是独立四舍五入;否则四档金额相加可能差一两美分,账龄表就对不上余额。第二,准备率放在一个常量里,改政策只改一处,不会出现四档比例互相矛盾。第三,同时返回准备率和逾期覆盖率:前者回答“整体准备够不够”,后者回答“逾期部分被覆盖了多少”,两个问题不能用一个数字回答。

账龄表最终会进入资产负债表的应收净额:应收总额减去坏账准备,才是报表上的净应收。这个减法在 3.3 资产负债表 里出现过,本节不重复。下一节把账龄表向前推一步:账龄是怎么形成的,开票、对账和催收的每个节点如何改变现金到账时间。

小结

  • 账龄表是应收余额的年龄结构,不是余额本身;四档相加必须等于应收总额。
  • 本节采用 0–30、31–60、61–90、90+ 四档,准备率 1%/5%/20%/50% 是教学假设,不是统计坏账率。
  • LedgerLite 的 12,000 美元应收中,逾期 720 美元,分档准备合计 224.40 美元,准备率 1.87%。
  • Nord Studio 的 11,264 美元应收中,逾期 901.12 美元,分档准备合计 243.31 美元,准备率 2.16%。
  • 统一比例口径给出 120 美元和 225.28 美元,低于分档结果;差额来自 90+ 档的 50% 准备。
  • 90+ 档只占应收的 0.9% 和 1.2%,却贡献了 24.1% 和 27.8% 的准备;尾部风险不能按余额占比看。
  • 四个比率足够读表:当前占比、逾期占比、90+ 占比、准备率;账龄表要按月滚动,不能当永久标签。

阅读导航:上一节:3.1 账期的真实成本:Net 15、30、60 差多少 · 下一节:3.3 开票、对账与催收节奏 。

常见错误与边界

  1. 把账龄表当成应收余额表。 余额表只有一行合计;账龄表要回答“这些钱分别有多老”,四档相加必须回到余额。
  2. 用一个统一比例计提所有应收。 LedgerLite 的 1% 统一比例给出 120 美元,分档给出 224.40 美元;差额 104.40 美元就是 90+ 尾部被抹平的部分。
  3. 把逾期率当坏账率。 6% 和 8% 是晚于约定日期的应收占比,不是最终损失比例;损失要看分档准备率与回收情况。
  4. 按发票日期而不是到期日分档。 账龄应按“到期后多少天”计算;按开票日会把长账期合同误判为老应收。
  5. 忘记预付款和贷项。 客户预付、贷项通知单和部分退款都会改变余额的年龄结构;直接拿开票总额分档会高估。
  6. 只更新余额,不更新账龄。 每月余额相同,可能是一批老账收回、一批新账变老;账龄结构已经完全不同。
  7. 把准备当成现金支出。 坏账准备是会计估计,减少账面利润和应收净额,不减少银行余额;现金影响发生在核销或收回时。
  8. 把 90+ 当成必然全损。 50% 是教学假设,不是“90 天以上一定收不回”;有争议但仍在沟通的应收,回收率可能高于这个比例。
  9. 忽略客户集中度。 一笔 90+ 的大客户应收,可能比十笔小客户更值得单独评估;集中度见 7.2 客户集中度 。
  10. 账龄表只做一次。 账龄是流动的;一个月不更新,表上的“当前”可能已经是上个月的“逾期”。

本节的边界是“给定逾期比例与逾期拆分”的静态分档。它假设每档内部的损失概率相同,不含客户信用差异、争议状态和回收努力。它足够回答“这笔余额该准备多少”,但不足以回答“这笔钱什么时候能收回来”。后者要把账龄表还原成一张回款时间分布,也就是下一节的开票、对账与催收节奏。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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