以下情境为教学假设,人物与数字不代表经核实的真实案例。
上一节的矩阵把条款翻译成了金额。这一节把金额放回资产负债表和风险登记册,回答一个更实际的问题:这些条款加在一起,最坏会吃掉多少 owner earnings?
答案不能只看应收。应收是已经开票、还没收到的钱;风险敞口还包括没有开票但已经发生的收入、平台压着不结算的余额、保险不赔的缺口。三者层层放大,也层层需要不同的管理动作。这一节按「应收 → 期望损失 → 未覆盖缺口」三层展开,最后给出两个比率:风险敞口 ÷ SDE,以及保障覆盖倍数。
应收只是第一层
应收回答「客户欠我多少」。它的质量由三个数决定:余额、逾期比例、坏账准备。余额是规模,逾期比例是回收难度,准备是会计上已经承认收不回来的部分。三个数缺一个,应收就只是一个总数。
第二层是风险期望损失。它不只包含应收,还包含责任索赔、平台下架、IP 侵权、关键人缺席等不一定经过应收的风险。12.1 的风险登记册用「影响金额 × 概率档位」把十行风险加总,得到三家的期望损失:LedgerLite $21,361、Tidecast $26,527、Nord $17,008。这个数字比应收大,因为它覆盖了应收之外的风险。
第三层是未覆盖缺口。期望损失是「平均一年会损失多少」,缺口是「一次最坏事件之后,保险和预案不管的部分还剩多少」。12.3 算过:LedgerLite $13,337、Tidecast $232,430、Nord $27,462。期望损失和缺口的量级不同,因为一个是概率加权,一个是尾部情景;两个都要看,不能用一个替代另一个。
三案例的应收与准备
先看第一层。下表把应收拆成余额、逾期、准备和周转天数。逾期率和准备率是案例设定,准备金额按应收乘比率计算。
| 案例 | 应收 | 逾期率 | 逾期额 | 准备率 | 准备额 | 净应收 | 周转天数 |
|---|---|---|---|---|---|---|---|
| LedgerLite | $12,000 | 6% | $720 | 1% | $120 | $11,880 | 18.23 天 |
| Tidecast | $0 | 不适用 | $0 | 0% | $0 | $0 | 0 天 |
| Nord Studio | $11,264 | 8% | $901.12 | 2% | $225.28 | $11,038.72 | 26.64 天 |
LedgerLite 的应收是年收入的 5.0%,周转 18.23 天,比合同上的 Net 15 多 3.23 天。差额来自年付与月付的混合:年付客户提前付钱,把平均占用拉低;月付客户的逾期把 DSO 拉高。两个方向抵消后,实际占用略高于合同账期。
Nord 的应收是年收入的 7.3%,周转 26.64 天,比 Net 30 少 3.36 天。差额来自预付款和产品线的即时扣款:咨询客户按里程碑付款,产品订阅按月扣卡,实际回款比合同账期快。账龄表的完整分档见 3.2 应收账龄表:从余额到风险分档 ,计提与核销的分界见 4.1 坏账准备:计提、核销与转回 。
Tidecast 的应收是 0。这不是因为它没有未收的钱,而是因为平台在中间收款:玩家付给 Steam,Steam 按月结算给开发者。应收科目为零,但平台欠它的钱以「未结算余额」的形式存在,下一节单独算。
第二层:风险期望损失
把 12.1 的登记册结果搬到同一张表,再加上保险和缺口,得到第二层和第三层的横向对照。金额单位是美元,比率按精确金额计算后四舍五入。
| 案例 | 风险期望损失 | 保险保额 | 未覆盖缺口 | 应收敞口 | 应收敞口 ÷ SDE | 期望损失 ÷ SDE | 缺口 ÷ SDE |
|---|---|---|---|---|---|---|---|
| LedgerLite | $21,361 | $1,000,000 | $13,337 | $12,000 | 9.2% | 16.4% | 10.2% |
| Tidecast | $26,527 | $0 | $232,430 | $8,597.25 | 7.1% | 21.9% | 191.8% |
| Nord Studio | $17,008 | $500,000 | $27,462 | $11,264 | 32.8% | 49.6% | 80.0% |
这张表最重要的不是绝对值,而是排序会变。按应收排,Nord 第二、LedgerLite 第一;按期望损失排,Tidecast 第一、LedgerLite 第二;按缺口排,Tidecast 第一、Nord 第二、LedgerLite 第三。三个排序对应三种管理动作:应收排序决定催收优先级,期望损失排序决定风险预算,缺口排序决定保险和交易结构。
LedgerLite 的三个比率最均衡:应收 9.2%、期望损失 16.4%、缺口 10.2%。它的风险不是「没有」,而是「已知且可承受」:一次最坏事件的自留部分在一年 SDE 的 10.2% 以内,不需要动权益或借债。
Nord 的应收比率最高:32.8% 的 SDE 压在应收上。它的期望损失比率 49.6%,接近一半的 owner earnings;缺口比率 80.0%,超过全年 SDE。Nord 的问题不是某一笔坏账,而是「两个人的工资 + 客户集中 + 没有书面退出条款」叠在一起,任何一层出事都会穿透到个人。
Tidecast 的应收比率最低,只有 7.1%,但缺口比率最高,191.8%。这正是「应收为零不等于风险为零」的量化版本:它的钱不在客户手里,在平台手里;平台不结算、下架或改变分成,损失会直接落到净得,而不经过应收科目。
第三层:未覆盖缺口与保障覆盖倍数
缺口是「最坏事件之后还要自己承担的部分」。它和保额的关系不是简单相减,因为保险有免赔额、除外责任和分项限额。用两个分母算覆盖倍数,可以回答两个不同的问题。
第一个分母是风险期望损失,回答「保额相对长期平均损失有多厚」:
| 案例 | 保额 | 期望损失 | 保额 ÷ 期望损失 | 读法 |
|---|---|---|---|---|
| LedgerLite | $1,000,000 | $21,361 | 46.8× | 保额远高于平均损失 |
| Tidecast | $0 | $26,527 | 0× | 没有保险 |
| Nord Studio | $500,000 | $17,008 | 29.4× | 保额高于平均损失 |
第二个分母是单次最大损失,回答「一次事故够不够赔」:
| 案例 | 保额 | 单次最大损失 | 保额 ÷ 单次最大损失 | 缺口 |
|---|---|---|---|---|
| LedgerLite | $1,000,000 | $280,947 | 3.56× | $10,000 |
| Tidecast | $0 | $110,537 | 0× | $88,430(另加 IP 缺口 $144,000) |
| Nord Studio | $500,000 | $136,200 | 3.67× | $5,000 |
两组倍数差了一个数量级。46.8× 看起来比 3.56× 安全得多,但它回答的是不同的问题:前者说「平均损失很小,保额很厚」,后者说「一次事故的合理上限,保额刚好覆盖三倍」。真正决定缺口的是后者,因为保险是按事故赔,不是按年度平均赔。
Tidecast 的 0× 不是「不需要保险」,而是「没有保险可算」。它的单次最大损失取 Steam 年净得 $110,537,因为平台下架会同时影响 80% 的销量;IP 缺口另算 $144,000,来自三年授权保底 $180,000 的 80%。两块加起来 $232,430,正好是上表的缺口。覆盖倍数为 0 时,缺口等于全部损失,没有任何一层在中间承接。
Nord 的 3.67× 只覆盖专业责任。E&O 不赔关键人缺席,也不赔客户不付钱;它的总缺口 $27,462 里,专业责任只占 $5,000,关键人 $16,830、应收 $5,632。保额看起来是单次最大损失的 3.67 倍,但把三类风险加总后,覆盖倍数降到 18.2×($500,000 ÷ $27,462),而且这个倍数没有意义——E&O 根本不为后两类风险赔付。
覆盖倍数要和缺口一起读。LedgerLite 的 3.56× 仍然留下 $10,000 免赔额;Nord 的 3.67× 只覆盖三类风险里的一类;Tidecast 的 0× 意味着全部自留。倍数大于 1 不等于没有缺口,倍数等于 0 也不等于没有保障——它只说明这一类风险没有对应的保单。
Tidecast 的零应收与平台未结算
Tidecast 的应收是 0,但平台并不是即时结算。Steam 按月结算,扣掉 30% 分成和 4% 退款后,把净得打给开发者。按年净得 $138,171 计算,其中 Steam 部分 $103,167;按月平均,任何时点都有约 $8,597.25 已经卖出但还没到账。
这个余额不进应收,因为法律上平台是收款方,开发者对平台的债权不是客户应收;但它在经济上就是一笔应收。矩阵把它记在「账期」格里,敞口表把它加进「应收敞口」:$0 + $8,597.25 = $8,597.25。金额不大,占 SDE 的 7.1%,但它的性质不同——客户分散在 9,600 个玩家手里,债务人却只有一个平台。
如果按 90 天看,平台未结算的收入暴露是 $19,079,占年净得 $138,171 的 13.8%。这个数字在 Q4 会更高,因为 12 月销量是月均的 3 倍;同样 30 天的结算周期,12 月的未结算余额是 1 月的数倍。季节性把「平均 8,597」变成「峰值可能超过 2 万」。
所以 Tidecast 的 0 应收要拆成三句话:客户应收为零,平台未结算 $8,597.25,90 天收入暴露 $19,079。三句话都成立,只是回答不同的问题。把 0 当成「没有应收风险」,会漏掉平台这个单一债务人。
从敞口到动作
按缺口占 SDE 排序,优先级是 Tidecast 191.8%、Nord 80.0%、LedgerLite 10.2%。但优先级不等于可执行性:Tidecast 能做的动作最少,因为平台条款不可谈;Nord 能做的动作最多,因为合伙人协议可以补;LedgerLite 只需要维持。
Tidecast 的动作是把缺口压到 50% 以下:买 IP 保险,或把 Steam 占比从 80% 降到 50%。12.3 算过,两个动作一起做,缺口从 $232,430 降到 $60,269,占 SDE 49.7%,刚好低于阈值。单做 IP 保险,缺口仍有 $93,430,占 77.1%;单降占比,缺口仍有 $71,322,占 58.9%。两个动作缺一不可。
Nord 的动作是补三类保障:关键人保险覆盖合伙人缺席,信用保险覆盖应收,合伙人协议写清退出定价。三项都做,缺口从 $27,462 降到 $5,000,占 SDE 14.6%。只补 E&O 没有用,因为 E&O 本来就已经在表里。
LedgerLite 的动作是维持:每年复核一次 $1M 保额是否还覆盖单次最大损失,$10,000 免赔额是否还在现金储备范围内。它的缺口只有 $13,337,不需要额外的保险或结构,但需要每年确认一次「已知且可承受」还成立。
这一节给的是基线,不是任务清单。基线的作用是让下一节的评审有输入:13.3 会用六组清单给三案例打分,把最弱的组映射到卷三的具体章节。没有这张敞口表,评分会变成印象分;有了这张表,每个低分都能指回一个金额。
写成代码:exposure.ts
代码把一张合同基线转成一行敞口。buildExposureRow 先算逾期额、准备额和净应收,再把平台未结算加进应收敞口;然后分别用 SDE 做分母,得到应收敞口、期望损失和缺口三个比率。三个比率分开存,不合并成一个「综合风险分」,因为它们的分子回答不同的问题。
turnoverDays 用 365 天,不用 360 天;coverageMultipleX100 用「保额 ÷ 期望损失 × 100」,这样 46.8 倍存成 4,681,避免浮点小数。判定档位用缺口占 SDE:50% 以下为 covered,50% 到 100% 为 watch,超过 100% 为 uncovered。三个阈值是教学假设,不是行业标准。
// file: src/contract/exposure.ts
// tier: L1
// verify: node --test test/contract/exposure.test.ts
import { type Cents, add } from "../core/money.ts";
import { type Bps, applyRate, divide } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";
export type ExposureInput = {
name: string;
revenueCents: Cents;
sdeCents: Cents;
receivableCents: Cents;
overdueBps: Bps;
provisionBps: Bps;
expectedLossCents: Cents;
insuranceLimitCents: Cents;
uncoveredGapCents: Cents;
platformUnsettledCents?: Cents;
};
export type ExposureRow = {
name: string;
turnoverDays: number;
overdueCents: Cents;
provisionCents: Cents;
netReceivableCents: Cents;
exposureCents: Cents;
exposureToSdeBps: Bps;
expectedLossToSdeBps: Bps;
coverageMultipleX100: number;
uncoveredGapCents: Cents;
gapToSdeBps: Bps;
verdict: "covered" | "watch" | "uncovered";
};
export type ExposureTable = {
rows: ExposureRow[];
totalExposureCents: Cents;
totalGapCents: Cents;
highestGapName: string;
};
function validAmount(value: Cents): boolean {
return Number.isInteger(value) && value >= 0;
}
function validRate(value: Bps): boolean {
return Number.isInteger(value) && value >= 0 && value <= 10000;
}
/**
* Turns one contract baseline into an exposure row. Three ratios are kept
* apart on purpose: receivables over SDE, expected loss over SDE, and the
* uncovered gap over SDE. They answer different questions, and averaging
* them would hide the case where a small receivable still carries a large
* expected loss. Turnover days use 365, not 360.
*/
export function buildExposureRow(input: ExposureInput): Result<ExposureRow> {
if (input.name.trim() === "") {
return fail("case name cannot be empty");
}
if (!validAmount(input.revenueCents) || input.revenueCents === 0) {
return fail("revenue must be a positive integer amount in cents");
}
if (!validAmount(input.sdeCents) || input.sdeCents === 0) {
return fail("SDE must be a positive integer amount in cents");
}
if (
!validAmount(input.receivableCents) ||
!validAmount(input.expectedLossCents) ||
!validAmount(input.insuranceLimitCents) ||
!validAmount(input.uncoveredGapCents)
) {
return fail("amounts must be non-negative integers in cents");
}
if (!validRate(input.overdueBps) || !validRate(input.provisionBps)) {
return fail("overdue and provision rates must be between 0 and 10000 bps");
}
const platformUnsettledCents = input.platformUnsettledCents ?? 0;
if (!validAmount(platformUnsettledCents)) {
return fail("platform unsettled amount must be non-negative");
}
const overdueCents = applyRate(input.receivableCents, input.overdueBps);
const provisionCents = applyRate(input.receivableCents, input.provisionBps);
const netReceivableCents = input.receivableCents - provisionCents;
const exposureCents = add(input.receivableCents, platformUnsettledCents);
const turnoverDays =
Math.round((input.receivableCents * 36500) / input.revenueCents) / 100;
const coverageMultipleX100 =
input.expectedLossCents === 0
? 0
: Math.round(
(input.insuranceLimitCents * 100) / input.expectedLossCents,
);
const gapToSdeBps = divide(input.uncoveredGapCents, input.sdeCents);
const verdict: ExposureRow["verdict"] =
gapToSdeBps <= 5000
? "covered"
: gapToSdeBps <= 10000
? "watch"
: "uncovered";
return ok({
name: input.name,
turnoverDays,
overdueCents,
provisionCents,
netReceivableCents,
exposureCents,
exposureToSdeBps: divide(exposureCents, input.sdeCents),
expectedLossToSdeBps: divide(input.expectedLossCents, input.sdeCents),
coverageMultipleX100,
uncoveredGapCents: input.uncoveredGapCents,
gapToSdeBps,
verdict,
});
}
/**
* Builds the three-case table and names the largest absolute gap. The gap is
* not a probability; it is the part of the worst case that neither insurance
* nor a documented recovery plan covers. The 50% and 100% SDE lines are
* teaching thresholds for "covered", "watch" and "uncovered".
*/
export function buildExposureTable(inputs: ExposureInput[]): Result<ExposureTable> {
if (inputs.length === 0) {
return fail("at least one exposure case is required");
}
const rows: ExposureRow[] = [];
for (const input of inputs) {
const row = buildExposureRow(input);
if (!row.ok) {
return row;
}
rows.push(row.value);
}
let totalExposureCents = 0;
let totalGapCents = 0;
let highestGapName = rows[0].name;
let highestGapCents = -1;
for (const row of rows) {
totalExposureCents = add(totalExposureCents, row.exposureCents);
totalGapCents = add(totalGapCents, row.uncoveredGapCents);
}
for (const row of rows) {
if (row.uncoveredGapCents > highestGapCents) {
highestGapName = row.name;
highestGapCents = row.uncoveredGapCents;
}
}
return ok({
rows,
totalExposureCents,
totalGapCents,
highestGapName,
});
}
把三家代进去,结果是 LedgerLite 的缺口占 SDE 10.2%,判定 covered;Nord 80.0%,判定 watch;Tidecast 191.8%,判定 uncovered。代码同时给出总敞口 $31,861.25 和总缺口 $273,229,最大缺口是 Tidecast。总缺口和 12.3 的合计差 60 美分,来自 Steam 缺口的四舍五入;这类小差在横向表里可以接受,但要在脚注里说明。
代码不处理风险相关性,也不处理多次事件叠加。它假设每一行独立、每一次事件只发生一次;真实世界里,平台下架和 IP 侵权可能同时发生,保险的年度总限额也会被多次索赔共享。这些要放进情景分析,不能塞进一个求和函数。
小结
- 应收只是第一层;第二层是风险期望损失,第三层是未覆盖缺口,三层不能互相替代。
- LedgerLite:应收 $12,000、逾期 6%、准备 1%、期望损失 $21,361、保险 $1M、缺口 $13,337。
- Tidecast:应收 $0,但平台未结算 $8,597.25、90 天收入暴露 $19,079、期望损失 $26,527、无 IP 险、缺口 $232,430。
- Nord:应收 $11,264、逾期 8%、准备 2%、期望损失 $17,008、E&O $500K、缺口 $27,462。
- 风险敞口 ÷ SDE 分三种:应收敞口 9.2%/7.1%/32.8%,期望损失 16.4%/21.9%/49.6%,缺口 10.2%/191.8%/80.0%。
- 保障覆盖倍数要两个分母:保额 ÷ 期望损失是 46.8×/0×/29.4×,保额 ÷ 单次最大损失是 3.56×/0×/3.67×。
- 倍数大不等于没有缺口;LedgerLite 的 3.56× 仍留 $10,000 免赔额,Nord 的 3.67× 只覆盖专业责任。
- 0 应收不等于 0 敞口;Tidecast 的债务人是平台,风险集中在结算、下架和分成条款。
- 排序会随口径变:催收看应收,预算看期望损失,保险与交易结构看缺口。
阅读导航:上一节:13.1 合同条款矩阵 · 下一节:13.3 卷二评审与进入卷三 。
常见错误与边界
- 把应收当成全部敞口。 平台未结算、责任索赔、IP 侵权都不在应收科目里,但都是敞口。
- 把 0 应收当成 0 风险。 Tidecast 的应收是 0,缺口是 $232,430;债务从客户变成了平台。
- 用期望损失替代缺口。 期望损失是概率加权,缺口是尾部情景;LedgerLite 的 16.4% 和 10.2% 是两个问题。
- 用缺口替代期望损失。 缺口只描述一次最坏事件,不描述一年内的平均损失;风险预算要用期望损失。
- 只算一个覆盖倍数。 保额 ÷ 期望损失和保额 ÷ 单次最大损失差一个数量级,回答的问题不同。
- 把保额当赔付。 免赔额、除外责任和分项限额会打折;LedgerLite 的 $1M 保额实际赔付 $270,947。
- 把准备率当坏账率。 准备是会计分摊,不是预测;1% 和 2% 是案例设定,不是统计结果。
- 忽略平台结算周期。 月结 30 天意味着任何时点都有一笔未结算余额;旺季的余额远高于平均值。
- 把三个比率合成一个分数。 合成会掩盖结构;应收、期望损失、缺口要分开看,才知道该催收、买保险还是改结构。
- 把 50% 阈值当行业标准。 它是教学假设;阈值取决于现金储备、融资能力和风险偏好。
本节的边界是「应收、期望损失、缺口与覆盖倍数的横向对照」:只覆盖三个案例和本卷已算过的数字,不展开保险定价、信用评级和坏账模型。所有概率、准备率和阈值都是教学假设;正式交易中的应收与保险评估应由专业人员完成。下一节会把这张敞口表和 13.1 的矩阵一起收进卷二评审,给出进入卷三的三个动作与三个交接问题。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。