以下情境为教学假设,人物与数字不代表经核实的真实案例。
合同里最容易被当成法务条款跳过的,是责任上限。它不决定你能卖多少钱,而决定一次事故最多让你赔多少。对一人公司来说,这个数字比折扣更危险:折扣少赚的是毛利,责任上限赔掉的是权益。
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,240 | 1.00× | 1.84× | 不能,需要 1.84 年 |
| 3 年合同金额 100% | $720,720 | 3.00× | 5.53× | 不能,需要 5.53 年 |
| 已付费用(第 6 个月,月付) | $120,120 | 0.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 期限、自动续约与退出窗口 。
常见错误与边界
- 把 12 个月费用和合同金额 100% 当成同一个条款。 一年期合同里它们相同,三年期合同里相差三倍;签字前先看合同期。
- 只看单份合同上限,不看合同数量。 LedgerLite 的单份 12 个月费用上限只有 $924,260 份加总才是 $240,240;只看单份会低估公司总敞口。
- 把已付费用当成永久低上限。 已付费用会随着时间追上来,第三年末可能接近合同金额;它不是把风险变小,而是把风险推迟。
- 认为有 $1M 保险就没有净敞口。 保险有保障范围、分项限额、自留额和除外责任;索赔不在保单内时,覆盖份额是 0,净敞口回到上限。
- 忽略上限的例外清单。 知识产权侵权、数据泄露、第三方索赔可能不受上限约束;只读上限数字,会漏掉最坏的那个场景。
- 把抗辩费用算进上限。 如果合同没有写明抗辩费用受上限约束,它可能在上限之外单独计算;净敞口要把它加回。
- 把公司年收入当成单份合同年费。 责任上限的基数是单份合同的费用;LedgerLite 的单份合同年费约 $924,$240,240 是 260 份合同的合计。
- 把低上限当成谈判优势。 低上限让卖方好签,但企业买方可能要求例外或直接排除;它不是免费的。
- 把高上限当成专业信号。 高上限让买方容易接受,但一次索赔可能超过公司权益;签字前要确认保险和除外条款能否兜住。
- 只在上限上谈判,不改变结构。 上限可以用价格、保险、除外责任、赔偿上限和合同期共同调整;只争一个数字,通常得不到想要的风险分布。
责任上限的财务问题,最终是一个「谁承担尾部」的问题。上限越低,尾部越靠近买方;上限越高,尾部越靠近卖方。保险、价格和除外条款,都是把尾部重新分配的工具。下一节讨论期限与自动续约:它们不改变一次事故的赔偿,但会改变收入确定性的时间分布。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。