《独立开发者的商业指南·合同、应收与风险》13.2 应收与风险敞口

应收只是风险敞口的一部分。本节把三案例的应收、逾期、坏账准备、风险期望损失、保险与未覆盖缺口放进同一张表,给出应收敞口占 SDE、期望损失占 SDE、缺口占 SDE 与两种保障覆盖倍数,并解释 Tidecast 为什么应收为零仍有 8,597 美元平台未结算敞口。

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

上一节的矩阵把条款翻译成了金额。这一节把金额放回资产负债表和风险登记册,回答一个更实际的问题:这些条款加在一起,最坏会吃掉多少 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,0006%$7201%$120$11,88018.23 天
Tidecast$0不适用$00%$0$00 天
Nord Studio$11,2648%$901.122%$225.28$11,038.7226.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,0009.2%16.4%10.2%
Tidecast$26,527$0$232,430$8,597.257.1%21.9%191.8%
Nord Studio$17,008$500,000$27,462$11,26432.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,36146.8×保额远高于平均损失
Tidecast$0$26,5270×没有保险
Nord Studio$500,000$17,00829.4×保额高于平均损失

第二个分母是单次最大损失,回答「一次事故够不够赔」:

案例保额单次最大损失保额 ÷ 单次最大损失缺口
LedgerLite$1,000,000$280,9473.56×$10,000
Tidecast$0$110,5370×$88,430(另加 IP 缺口 $144,000)
Nord Studio$500,000$136,2003.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 卷二评审与进入卷三 。

常见错误与边界

  1. 把应收当成全部敞口。 平台未结算、责任索赔、IP 侵权都不在应收科目里,但都是敞口。
  2. 把 0 应收当成 0 风险。 Tidecast 的应收是 0,缺口是 $232,430;债务从客户变成了平台。
  3. 用期望损失替代缺口。 期望损失是概率加权,缺口是尾部情景;LedgerLite 的 16.4% 和 10.2% 是两个问题。
  4. 用缺口替代期望损失。 缺口只描述一次最坏事件,不描述一年内的平均损失;风险预算要用期望损失。
  5. 只算一个覆盖倍数。 保额 ÷ 期望损失和保额 ÷ 单次最大损失差一个数量级,回答的问题不同。
  6. 把保额当赔付。 免赔额、除外责任和分项限额会打折;LedgerLite 的 $1M 保额实际赔付 $270,947。
  7. 把准备率当坏账率。 准备是会计分摊,不是预测;1% 和 2% 是案例设定,不是统计结果。
  8. 忽略平台结算周期。 月结 30 天意味着任何时点都有一笔未结算余额;旺季的余额远高于平均值。
  9. 把三个比率合成一个分数。 合成会掩盖结构;应收、期望损失、缺口要分开看,才知道该催收、买保险还是改结构。
  10. 把 50% 阈值当行业标准。 它是教学假设;阈值取决于现金储备、融资能力和风险偏好。

本节的边界是「应收、期望损失、缺口与覆盖倍数的横向对照」:只覆盖三个案例和本卷已算过的数字,不展开保险定价、信用评级和坏账模型。所有概率、准备率和阈值都是教学假设;正式交易中的应收与保险评估应由专业人员完成。下一节会把这张敞口表和 13.1 的矩阵一起收进卷二评审,给出进入卷三的三个动作与三个交接问题。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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