《独立开发者的商业指南·合同、应收与风险》2.1 责任上限:最坏损失怎么算

责任上限不是法务条款,而是最坏损失的定价器。本节把 LedgerLite 的 12 个月费用、合同金额 100%、已付费用三种写法放到 1 年与 3 年合同上,算出 $240,240 与 $720,720 两档上限,再用网络安全险 $1M 折算净敞口,并说明低上限好签、高上限难签的取舍。

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

合同里最容易被当成法务条款跳过的,是责任上限。它不决定你能卖多少钱,而决定一次事故最多让你赔多少。对一人公司来说,这个数字比折扣更危险:折扣少赚的是毛利,责任上限赔掉的是权益。

LedgerLite 全部客户的年费合计是 $240,240(260 家 × $77/月 × 12),SDE 是 $130,240。如果合同组合写「12 个月费用」,公司层面的累计上限就是 $240,240,相当于 1.84 年 SDE;如果写「合同金额 100%」而合同期是三年,累计上限就变成 $720,720,相当于 5.53 年 SDE。同一门生意,同一组客户,只因为上限的写法不同,最坏损失可以相差三倍。

这一节只做三件事:把三种常见写法放到同一张表上,算出最坏损失,再用保险折算净敞口。它不提供法律意见,也不判断条款是否可执行;它只回答一个财务问题:如果最坏情况发生,你要自己掏出多少钱。

三种写法,三个不同的数

责任上限的写法通常有三种:按 12 个月费用封顶、按合同金额的 100% 封顶、按已付费用封顶。三者在一年期合同上可能给出同一个数,但合同期一拉长,就会分叉。

按平均 ARPA $924/年($77 × 12)计算,单份合同和 260 份合同合计的数字如下:

写法单份 1 年合同单份 3 年合同单份第 6 个月(月付)260 份 3 年合同第 6 个月(月付)
12 个月费用$924$924$924$240,240
合同金额 100%$924$2,772$2,772$720,720
已付费用$924累计至 $2,772$462$120,120

前两行不随已过月数变化;「已付费用」随月数变化:1 年/3 年列按年付计,第 6 个月列按月付计,最后一列是第 6 个月的时点数,不是合同期末数。

第一行和第三行的差别最容易被忽略。一年期合同里,「12 个月费用」和「合同金额 100%」是同一个数;只有合同期超过 12 个月,两者才分叉。三年期合同按 100% 封顶,单份上限是年费的三倍;按 12 个月费用封顶,单份上限仍然是年费。销售在报价时通常只盯着第一年的年费,但责任上限跟着合同期走,签字那一刻就把后两年的风险一起买下来了。

「已付费用」看起来最温和,因为它从零开始。单份月付合同在第 6 个月只付了半年费用,上限是 $462;260 份合计是 $120,120。年付客户在签约时已经付了全年费用,单份上限仍是 $924;260 份合计是 $240,240。它的问题是时间不对称:越往后,累计已付费用越接近合同金额,上限会慢慢追上来。到了第三年末,三种写法可能收敛到同一个数,但早期的风险分布完全不同。

还有一个口径必须写清楚:上限是按「每个客户」还是「每个事件」计算,是按「单次索赔」还是「合同期内累计」计算。按平均 ARPA $924/年计算,单份合同的 12 个月费用上限只有 $924;260 份合同同时出事,累计上限是 $240,240。单份合同的上限不等于公司层面的总敞口;后者要用客户数或合同数乘出来。

口径单份合同上限260 份合同同时出事的累计上限
12 个月费用$924$240,240
合同金额 100%(1 年)$924$240,240
合同金额 100%(3 年)$2,772$720,720

这张表不是说要为 260 份合同同时出事做准备,而是说明「单份合同的上限」和「公司的最坏损失」不是同一个量。前者是条款,后者是条款乘以合同数量。单份合同的 $924 看起来很小,260 份加总就是全年收入 $240,240;真正的风险预算应该按后者做,至少要知道它在什么量级。

最坏损失:上限乘以年限

把 260 份合同的上限加总,再放回 LedgerLite 的财务盘子里,才能看出它有多重。年收入 $240,240,SDE $130,240,毛利率 84.2%。合同组合的累计上限是年费的倍数,也是 SDE 的倍数。

累计上限写法金额相当于年收入相当于 SDE用一年 SDE 能否赚回
12 个月费用$240,2401.00×1.84×不能,需要 1.84 年
3 年合同金额 100%$720,7203.00×5.53×不能,需要 5.53 年
已付费用(第 6 个月,月付)$120,1200.50×0.92×接近,但仍需 0.92 年

「12 个月费用」之所以常见,是因为它把上限锚在一年收入上,买卖双方都能理解。对 LedgerLite 来说,$240,240 已经是 1.84 年 SDE;也就是说,一次全额索赔会吃掉近两年的可支配收益。这个数字还没有算抗辩费用、客户流失和续约受损,只算了直接赔偿。

「合同金额 100%」在三年期合同上更重。$720,720 是 5.53 年 SDE,超过 LedgerLite 全部权益 $80,000 的九倍。即使公司把未来三年的 SDE 全部拿来赔,也不够;股东需要追加资本,或者公司直接进入无力偿付。这就是为什么同一句「100%」,在一年期合同里看起来正常,在三年期合同里可能是致命的。

「已付费用」在早期最轻,但它有一个反向问题:客户付得越多,上限越高,而客户通常是在服务已经交付、价值已经实现之后才付到后期。也就是说,风险最高的时点,恰好是上限最高的时点。它并没有把风险变小,只是把风险推迟。

这里就能看出「上限越低越好卖、越高越难签」的取舍。对卖方,低上限容易点头,合同好卖;但企业买方会把低上限当成供应商不可靠的信号,采购和法务会要求例外,或者直接把供应商排除。高上限让买方容易接受,却让卖方难签:一次索赔可能超过公司全部权益,签字的人要拿自己的公司做抵押。低上限不是「更有利」,高上限也不是「更专业」;它们是把风险从一方转移到另一方的价格。

上限水平卖方签字意愿买方接受度常见结果
已付费用(早期)高低小客户能签,企业客户要求例外
12 个月费用中中最常见的折中点
合同金额 100%低高大客户能签,卖方需要保险或除外
无上限极低极高一人公司通常不应签

还有一个容易被上限遮住的口子:赔偿(indemnity)不一定受上限约束。知识产权侵权、数据泄露、第三方索赔,有时会被写成「不受责任上限限制」的单独条款。如果合同里有一条例外,上限就只覆盖普通违约,不覆盖最坏的那个场景。读上限时,必须同时读它的例外清单;只读数字,等于只读了一半。

保险之后的净敞口

LedgerLite 有网络安全险 $1M。$1M 是年费的 4.16 倍,是 1 年期上限的 4.16 倍,也是 3 年期上限的 1.39 倍。单看限额,保险似乎把两档上限都盖住了。但保险改变的不是上限,而是「上限里由谁支付」;它不改变合同上你欠对方多少钱。

设上限为 C,保单对可保事件的覆盖份额为 s,保额为 L,则净敞口可以写成:

净敞口 = C − min(C, L) × s

当 L ≥ C 且 s = 100% 时,净敞口为 0;当索赔不在保单范围内时,s = 0,净敞口回到 C。把 s 从 100% 调到 0%,可以得到一张敏感度表:

保单覆盖份额1 年上限 $240,240 的净敞口3 年上限 $720,720 的净敞口
100%$0$0
75%$60,060$180,180
50%$120,120$360,360
25%$180,180$540,540
0%$240,240$720,720

这张表的作用,是把「有保险」翻译成「还剩多少自己承担」。$1M 的保额不是一张 $1M 的支票,而是一个受保障范围、分项限额、自留额和除外责任约束的上限。网络安全险通常围绕数据泄露、隐私事件和相关的响应成本设计,并不自动覆盖所有合同违约或知识产权索赔;具体范围要看保单,不能把 $1M 当成万能覆盖。

净敞口还要加上上限之外的项目。抗辩费用如果不在上限内,或者合同里有不受上限约束的赔偿条款,这些金额要单独加回。上面的表只覆盖「上限之内、保单可保」的部分,所以它是净敞口的下限,不是上限。做风险预算时,至少要同时看三个数:合同上限、保单覆盖份额、上限之外的未保险项。

项目是否受上限约束是否在保单内计入净敞口的方式
服务违约赔偿通常受通常不在上限 ×(1 − 覆盖份额)
数据泄露响应通常受通常在上限 ×(1 − 覆盖份额)
知识产权侵权可能不受通常不在全额加回
抗辩费用可能不受视保单全额或按保单加回
罚款与处罚通常不受通常不在全额加回

代码:上限与净敞口

下面的 liability-cap.ts 把三种上限写法和保险折算固化成两个函数。computeCap 接受年费、合同月数、上限写法和已过月数,返回合同金额与上限;netExposure 接受上限、保额和覆盖份额,返回已覆盖金额、未保险金额和净敞口。金额用整数美分,比率用基点,避免浮点误差。

// file: src/contract/liability-cap.ts
// tier: L1
// verify: node --test test/contract/liability-cap.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 CapBasis = "twelve-months-fees" | "contract-value" | "fees-paid";

export type CapInput = {
  annualFeeCents: Cents;
  contractMonths: number;
  basis: CapBasis;
  monthsElapsed: number;
  annualPrepay: boolean;
};

export type CapResult = {
  basis: CapBasis;
  annualFeeCents: Cents;
  contractValueCents: Cents;
  capCents: Cents;
};

export type InsuranceInput = {
  capCents: Cents;
  coverageLimitCents: Cents;
  coveredShareBps: Bps;
};

export type Exposure = {
  capCents: Cents;
  coverageLimitCents: Cents;
  coveredCents: Cents;
  uninsuredCents: Cents;
  netExposureCents: Cents;
  coverageRatioBps: Bps;
};

const BASES: CapBasis[] = ["twelve-months-fees", "contract-value", "fees-paid"];

/**
 * The three common caps do not produce the same number once the term is longer
 * than one year. Twelve months of fees stays fixed; a percentage of contract
 * value grows with the term; fees paid starts at zero and converges upward.
 */
export function computeCap(input: CapInput): Result<CapResult> {
  if (!Number.isInteger(input.annualFeeCents) || input.annualFeeCents <= 0) {
    return fail("annual fee must be a positive integer amount in cents");
  }
  if (!Number.isInteger(input.contractMonths) || input.contractMonths <= 0) {
    return fail("contract months must be a positive integer");
  }
  if (!Number.isInteger(input.monthsElapsed) || input.monthsElapsed < 0) {
    return fail("elapsed months must be a non-negative integer");
  }
  if (input.monthsElapsed > input.contractMonths) {
    return fail("elapsed months cannot exceed contract months");
  }
  if (!BASES.includes(input.basis)) {
    return fail("unknown liability cap basis");
  }

  const contractValueCents = Math.round(
    (input.annualFeeCents * input.contractMonths) / 12,
  );

  let capCents: Cents;
  if (input.basis === "twelve-months-fees") {
    capCents = Math.min(input.annualFeeCents, contractValueCents);
  } else if (input.basis === "contract-value") {
    capCents = contractValueCents;
  } else if (input.annualPrepay) {
    const paidYears = Math.max(1, Math.ceil(input.monthsElapsed / 12));
    capCents = Math.min(input.annualFeeCents * paidYears, contractValueCents);
  } else {
    capCents = Math.round((input.annualFeeCents * input.monthsElapsed) / 12);
  }

  return ok({
    basis: input.basis,
    annualFeeCents: input.annualFeeCents,
    contractValueCents,
    capCents,
  });
}

/**
 * The cap fixes the contractual worst loss. Insurance does not change the cap;
 * it only changes who pays the part that the policy covers. A coverage share
 * below 100% is how excluded claims, sublimits and retained amounts enter the
 * model without pretending the policy covers every claim type.
 */
export function netExposure(input: InsuranceInput): Result<Exposure> {
  if (!Number.isInteger(input.capCents) || input.capCents < 0) {
    return fail("cap must be a non-negative integer amount in cents");
  }
  if (
    !Number.isInteger(input.coverageLimitCents) ||
    input.coverageLimitCents < 0
  ) {
    return fail("coverage limit must be a non-negative integer amount in cents");
  }
  if (
    !Number.isInteger(input.coveredShareBps) ||
    input.coveredShareBps < 0 ||
    input.coveredShareBps > 10000
  ) {
    return fail("covered share must be between 0 and 10000 bps");
  }

  const insurableCents = Math.min(input.capCents, input.coverageLimitCents);
  const coveredCents = applyRate(insurableCents, input.coveredShareBps);
  const uninsuredCents = input.capCents - coveredCents;

  return ok({
    capCents: input.capCents,
    coverageLimitCents: input.coverageLimitCents,
    coveredCents,
    uninsuredCents,
    netExposureCents: uninsuredCents,
    coverageRatioBps: divide(coveredCents, input.capCents),
  });
}

代码不区分单份合同和合同组合:传单份年费 $924,得到单份上限;传 260 份合计年费 $240,240,得到组合上限。测试用的是后一种口径,所以断言里的上限是 $240,240 与 $720,720。

代码里有三个口径值得注意。第一,twelve-months-fees 取 min(年费, 合同金额),所以六个月合同不会出现「上限大于合同金额」的怪数。第二,fees-paid 在年付时按整年跳档,在月付时按已过月数线性累计;两种付款节奏的早期上限差别很大。第三,netExposure 先用 min(上限, 保额) 算出可保金额,再乘覆盖份额;保额低于上限时,超出部分自动留在净敞口里。

Nord 与 Tidecast 的对照

Nord 的责任上限是合同金额 100%,保险是 E&O $500K。Nord 年收入 $154,320,把一年内全部合同金额加总作为极端上界,E&O 限额是它的 3.24 倍;但 E&O 是累计限额,多次索赔会共同消耗,单份合同的 100% 上限仍可能把一次失败交付放大成整份合同金额。

Tidecast 的平台发行协议里,责任上限由平台单方定义,且没有保险。卖方既不能谈判上限,也不能用保额算出净敞口;平台协议的最坏损失不是一个可计算的数,而是一个「平台说了算」的区间。这比上限偏高更麻烦:偏高至少可以定价,不可计算则无法预算。

小结

  • 责任上限是合同里最直接的财务条款:它不决定收入,决定一次事故最多赔多少。
  • 三种写法在一年期合同上可能相同,但三年期合同会分叉:12 个月费用保持 $240,240,合同金额 100% 变成 $720,720。
  • $240,240 是 LedgerLite 的 1.00 倍年收入、1.84 倍 SDE;$720,720 是 3.00 倍年收入、5.53 倍 SDE,超过全部权益。
  • 单份合同的上限是 $924,260 份加总才是 $240,240;公司层面的风险预算要按合同数量乘出来。
  • 保险不改变上限,只改变谁支付:净敞口 = 上限 − min(上限, 保额) × 覆盖份额。
  • 低上限好签但买方接受度低,高上限好卖但卖方风险高;真正的取舍是用价格、保险和除外条款换风险。
  • 上限之外的赔偿、抗辩费用和罚款要单独加回;只读上限数字,等于只读了一半条款。

阅读导航:上一节:1.3 价格、调价与付款条款:金额、时点、前提与违约责任 · 下一节:2.2 期限、自动续约与退出窗口 。

常见错误与边界

  1. 把 12 个月费用和合同金额 100% 当成同一个条款。 一年期合同里它们相同,三年期合同里相差三倍;签字前先看合同期。
  2. 只看单份合同上限,不看合同数量。 LedgerLite 的单份 12 个月费用上限只有 $924,260 份加总才是 $240,240;只看单份会低估公司总敞口。
  3. 把已付费用当成永久低上限。 已付费用会随着时间追上来,第三年末可能接近合同金额;它不是把风险变小,而是把风险推迟。
  4. 认为有 $1M 保险就没有净敞口。 保险有保障范围、分项限额、自留额和除外责任;索赔不在保单内时,覆盖份额是 0,净敞口回到上限。
  5. 忽略上限的例外清单。 知识产权侵权、数据泄露、第三方索赔可能不受上限约束;只读上限数字,会漏掉最坏的那个场景。
  6. 把抗辩费用算进上限。 如果合同没有写明抗辩费用受上限约束,它可能在上限之外单独计算;净敞口要把它加回。
  7. 把公司年收入当成单份合同年费。 责任上限的基数是单份合同的费用;LedgerLite 的单份合同年费约 $924,$240,240 是 260 份合同的合计。
  8. 把低上限当成谈判优势。 低上限让卖方好签,但企业买方可能要求例外或直接排除;它不是免费的。
  9. 把高上限当成专业信号。 高上限让买方容易接受,但一次索赔可能超过公司权益;签字前要确认保险和除外条款能否兜住。
  10. 只在上限上谈判,不改变结构。 上限可以用价格、保险、除外责任、赔偿上限和合同期共同调整;只争一个数字,通常得不到想要的风险分布。

责任上限的财务问题,最终是一个「谁承担尾部」的问题。上限越低,尾部越靠近买方;上限越高,尾部越靠近卖方。保险、价格和除外条款,都是把尾部重新分配的工具。下一节讨论期限与自动续约:它们不改变一次事故的赔偿,但会改变收入确定性的时间分布。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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