以下情境为教学假设,人物与数字不代表经核实的真实案例。
合同签下去的那一刻,真正被固定的不是合作关系,而是未来十二个月的现金路径。一份企业订阅协议可以写三十页,但决定财务结果的通常只有八个位置:谁欠钱、做什么、多少钱、多久、赔多少、IP 归谁、数据出事谁付、怎么退出。把这八个位置各翻译成一个数字,合同才从「签了」变成「能算」。
LedgerLite 的企业订阅协议是一个适合做样本的合同:260 家付费客户,ARPA 77 美元/月,ARR 240,240 美元;账期 Net 15;年末应收 12,000 美元,约等于 18 天收入;递延收入 22,000 美元;责任上限是 12 个月费用;SLA 99.5%,抵扣 10%;数据条款以 DPA(数据处理协议)形式附在协议后;网络安全险保额 1,000,000 美元。
Nord Studio 的咨询合同是另一种结构:年收入 154,320 美元,咨询服务占 72.7%,按 85 美元/小时、每月 110 小时计费;账期 Net 30,付款前提是验收;应收占年收入 7.3%,逾期率 8%,坏账准备 2%;责任上限是合同金额的 100%;E&O(专业责任险)保险 500,000 美元。LedgerLite 的年收入约为 Nord 的 1.6 倍,但合同把现金锁住的方式完全不同。
Tidecast 提供第三种参照:它的平台发行协议不是双方逐条谈出来的合同,而是平台单方更新的标准条款。Steam 占销量 80%,平台按月结算,账期 30 天;音乐是买断授权,不得转授权。你能算的仍然是最坏损失、现金占用和收入可转移性,只是几乎没有条款可以改,能改的只有渠道结构和现金储备。
八个部分:一张表看清财务落点
合同的结构可以从两页写到三十页,但财务上需要回答的问题只有八个。下表把每个部分对应到一个最重要的财务量,并给出 LedgerLite 的落点。这里的「最重要」不是法律重要性,而是它最先改变哪一行财务数字。
| 部分 | 它回答的问题 | 最重要的财务量 | LedgerLite 的落点 | 缺失后果 |
|---|---|---|---|---|
| 当事人与主体 | 谁签、谁付、出了问题找谁 | 可执行的回款主体 | 新加坡 Pte Ltd 对 260 家客户收款 | 合同主体与付款主体不一致,回款被拖 |
| 范围与交付 | 做什么、做到什么程度 | 收入确认时点与履约成本上限 | 订阅服务持续提供,按月确认 | 范围蔓延,交付成本吞掉毛利 |
| 价格与付款 | 多少钱、什么时候付 | 现金占用天数与合同金额 | Net 15,ARPA 77 美元/月 | 账期写错,现金被长期占用 |
| 期限与续约 | 多久、怎么续、怎么停 | 年度合同价值 × 续约年数 | ARR 240,240,自动续约 | 续约前没有通知,收入突然归零 |
| 责任与赔偿 | 最坏赔多少 | 责任上限 + 未保险部分 | 12 个月费用 | 一次索赔吃掉多年利润 |
| 知识产权 | 谁拥有、能不能转授权 | 可转移的资产价值或授权收入 | 只授予订阅使用权 | 买家用不了、卖不掉、授权不出去 |
| 数据与保密 | 数据出事谁付钱 | 泄露成本减保险赔付 | DPA + 网络安全险 1,000,000 美元 | 一次泄露把公司现金流打穿 |
| 终止与争议 | 怎么退出、去哪里解决 | 退出成本与未确认收入 | 递延收入 22,000 美元 | 退出时才发现退款和过渡义务 |
这张表的价值不在于把合同拆成八块,而在于每一块都能指到一个数字。指不到数字的部分,通常不是不重要,而是还没写清楚。比如「知识产权」在订阅合同里看起来只是标准条款,但如果客户要求把定制部分的代码所有权转走,下一份合同的可复用资产就少了一块;「数据与保密」如果没有 DPA,泄露后的通知、取证、赔偿都要临时谈,临时谈出来的数字通常最贵。
还有一个容易被忽略的排序问题:八个部分不是等权。对一人 SaaS,价格与付款、责任与赔偿、数据与保密是三个大头;对两人咨询工作室,范围与交付、终止与争议、责任与赔偿更要紧;对买断制游戏,平台条款和 IP 授权几乎决定全部。合同审查的时间应该按这个顺序分配,而不是从第一页读到最后一页。
每个部分最重要的那个数
先看当事人与主体。合同上的签约主体、发票抬头和实际付款账户必须是同一个可执行主体。LedgerLite 是新加坡 Pte Ltd,260 家客户里如果有集团客户用子公司签约、母公司付款,回款路径就多一层。这里的财务量不是「合同金额」,而是「可执行的回款金额」:签约主体没有资产,判决也执行不到钱。对一人公司来说,客户主体资质比合同措辞更早决定这笔钱能不能收回。
范围与交付决定两件事:收入什么时候确认,成本最多花到哪里。LedgerLite 的订阅是持续履约,收入按月确认;Nord 的咨询按项目交付,验收前收入不能确认。范围写得越宽,履约成本上限越高。第 1.2 节会把验收条件单独拆开,因为「做完」如果不可判定,收入确认和回款都会被同一个争议卡住。
价格与付款是合同金额和现金时点的组合。LedgerLite 的 ARPA 是 77 美元/月,Net 15;Nord 是 85 美元/小时、Net 30。价格相同、账期不同,现金占用可以差一倍。第 1.3 节会给 Net 15、Net 30、Net 60 的现金占用表,并把金额、时点、前提、违约四个要素拆开。
期限与续约的财务量是年度合同价值乘以续约年数。LedgerLite 的 ARR 是 240,240 美元;如果续约率下降,损失不是一个月 MRR,而是整年的合同价值。自动续约和终止通知期决定这个数字能不能保住,第 2.2 节会展开。对订阅生意,期限条款是收入可持续性的一部分,不是行政条款。
责任与赔偿是最坏损失的第一层。LedgerLite 的上限是 12 个月费用:对一个 77 美元/月的客户,上限是 924 美元;对最大客户(占收入 9%,约 21,622 美元/年),上限是 21,622 美元;如果把 260 家客户的上限简单相加,得到的是 ARR 240,240 美元。这个相加值不可同时发生,但它能说明量级:责任上限不是「一点点赔偿」,它和一年的收入在同一个数量级。Nord 的上限是合同金额的 100%,如果年度咨询合同总额是 112,200 美元,上限就是 112,200 美元。最坏损失还要加上未保险部分。
知识产权条款的财务量是「可转移价值」。LedgerLite 卖的是订阅使用权,客户不能拿走代码;如果素材是买断授权且不得转授权,就不能把同一段素材再授权给第三方;Nord 的定制网站如果约定版权归客户,下一次类似项目就不能复用。IP 归属不清,合同金额再高也可能是一次性收入,估值时买方会把不可转移的部分直接扣掉。
数据与保密条款的财务量是「出事后谁付钱」。LedgerLite 有 DPA 和 1,000,000 美元网络安全险;Nord 有 500,000 美元 E&O,但 E&O 通常不覆盖所有数据事件。保险不是免责,保额、免赔额、除外责任决定了自付部分。第 11 章会把 DPA、SCC(标准合同条款)、泄露通知和赔偿路径拆开,这里只需要记住:数据条款的终点是一张现金账单,不是一份隐私政策。
终止与争议条款的财务量是退出成本和未确认收入。LedgerLite 的递延收入是 22,000 美元;如果客户中途终止,未履约部分要么退款,要么继续服务。Nord 的咨询合同如果约定「验收后付款」,终止时已经投入的工时可能收不回来。争议解决地点影响的是执行成本,不是合同金额;对跨境合同,执行成本有时比争议金额更高。
这些条款的法律效力由适用法律决定,本节只计算它们对现金流和损失的影响。把法律判断留给专业人士,把财务判断留在自己的表里,两者不能互相替代。
三层财务后果:收入、现金与尾部风险
八个部分最终会落到三层财务后果上。把它们分开看,才不会用「合同金额」一个数字掩盖三种完全不同的风险。
| 层 | 受影响的财务量 | 八个部分中的位置 | 计算方式 |
|---|---|---|---|
| 收入层 | 收入确认时点、ACV、续约年数 | 范围与交付、期限与续约、知识产权 | 收入 = 合同金额 × 履约进度;ACV × 续约年数 |
| 现金层 | 应收余额、DSO、递延收入 | 价格与付款、终止与争议、当事人与主体 | 应收 = 年收入 × 账期 / 365;DSO = 应收 / 年收入 × 365 |
| 尾部层 | 责任上限、保险自付、数据泄露成本 | 责任与赔偿、数据与保密、知识产权 | 最坏损失 = 责任上限 + 未保险部分 |
三层里,收入层最容易被高估,现金层最容易被忽略,尾部层最容易被推迟。LedgerLite 的合同把三层都写了:订阅按月确认收入,Net 15 决定现金占用,12 个月费用上限和 1,000,000 美元保险决定尾部。Nord 的合同在收入层和现金层都更脆弱:验收后付款把现金时钟交给客户,100% 合同金额上限把最坏损失直接绑在合同规模上。
LedgerLite 的应收是 12,000 美元,约占年收入 5.0%,折合 18 天收入;合同账期是 Net 15,实际 DSO 比账期多 3 天。这 3 天不是合同写错,而是开票批次、银行转账和客户付款习惯共同造成的。合同条款给的是一个下限或上限,实际现金路径还要加上运营摩擦。第 1.3 节会把这段差距量化。
Nord 的应收占年收入 7.3%,约 11,265 美元,折合 27 天收入;合同账期是 Net 30,实际 DSO 反而比账期少 3 天。这也不矛盾:验收后付款的时钟从验收开始,应收账龄只统计「已开票未收回」的部分,验收等待期不在账龄表里。也就是说,Nord 的真实现金周期是「交付 → 验收 → 开票 → 回款」,账龄只显示了最后一段。第 1.2 节会说明这段被账龄表藏起来的时间值多少钱。
尾部层的量级和收入层不同。LedgerLite 的 12 个月费用上限对单个客户是 924 美元到 21,622 美元,看起来不大;但数据泄露、IP 侵权、服务中断叠加时,损失可能超过单份合同的上限,保险的免赔额和除外责任才是自付部分。Nord 的 100% 合同金额上限,如果年度咨询合同总额是 112,200 美元,上限就是 112,200 美元,而它的 SDE 只有 34,320 美元;一次上限级别的索赔,等于三年多的 SDE。这就是为什么责任条款不能只让律师看。
用代码检查合同是否每个部分都有数字
下面的 terms.ts 把八个部分固化成结构。它不判断条款写得好不好,只回答两个问题:八个部分是否都在,是否每个都带了金额。valueCents 用 null 表示「还没有数字」,用 0 表示「数字是零」;这个区别很重要,因为 IP 授权收入可以是 0,但不能是「没想过」。
// file: src/contract/terms.ts
// tier: L1
// verify: node --test test/contract/terms.test.ts
import { type Cents } from "../core/money.ts";
import { type Bps, divide } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";
export type ContractPartKey =
| "parties"
| "scope"
| "price"
| "term"
| "liability"
| "ip"
| "data"
| "termination";
export const CONTRACT_PART_KEYS: ContractPartKey[] = [
"parties",
"scope",
"price",
"term",
"liability",
"ip",
"data",
"termination",
];
export const CONTRACT_PART_LABELS: Record<ContractPartKey, string> = {
parties: "当事人与主体",
scope: "范围与交付",
price: "价格与付款",
term: "期限与续约",
liability: "责任与赔偿",
ip: "知识产权",
data: "数据与保密",
termination: "终止与争议",
};
export const CONTRACT_PART_METRICS: Record<ContractPartKey, string> = {
parties: "可执行的回款主体",
scope: "收入确认时点与履约成本上限",
price: "现金占用天数与合同金额",
term: "年度合同价值 × 续约年数",
liability: "最坏损失 = 责任上限 + 未保险部分",
ip: "可转移的资产价值或授权收入",
data: "泄露成本减保险赔付后的自付额",
termination: "退出成本与未确认收入",
};
export type ContractPart = {
key: ContractPartKey;
valueCents: Cents | null;
basis: string;
};
export type PartAssessment = {
key: ContractPartKey;
label: string;
metric: string;
valueCents: Cents | null;
basis: string;
quantified: boolean;
};
export type ContractAssessment = {
parts: PartAssessment[];
missing: ContractPartKey[];
unquantified: ContractPartKey[];
coveredBps: Bps;
complete: boolean;
};
function isPartKey(value: string): value is ContractPartKey {
return (CONTRACT_PART_KEYS as string[]).includes(value);
}
/**
* Check a contract against the eight-part structure. A part is present when
* it has a key and a basis; it is quantified only when it also carries an
* integer amount. Zero is a valid amount, so `null` is the only way to say
* "this part has no number yet".
*/
export function assessContract(
parts: ContractPart[],
): Result<ContractAssessment> {
if (parts.length === 0) {
return fail("contract needs at least one part");
}
const seen = new Set<ContractPartKey>();
for (const part of parts) {
if (!isPartKey(part.key)) {
return fail(`unknown contract part: ${part.key}`);
}
if (seen.has(part.key)) {
return fail(`duplicate contract part: ${part.key}`);
}
if (part.basis.trim() === "") {
return fail(`part ${part.key} needs a basis`);
}
if (
part.valueCents !== null &&
(!Number.isInteger(part.valueCents) || part.valueCents < 0)
) {
return fail(`part ${part.key} must carry a non-negative integer amount`);
}
seen.add(part.key);
}
const byKey = new Map(parts.map((part) => [part.key, part]));
const ordered: PartAssessment[] = [];
for (const key of CONTRACT_PART_KEYS) {
const part = byKey.get(key);
if (!part) {
continue;
}
ordered.push({
key,
label: CONTRACT_PART_LABELS[key],
metric: CONTRACT_PART_METRICS[key],
valueCents: part.valueCents,
basis: part.basis,
quantified: part.valueCents !== null,
});
}
const missing = CONTRACT_PART_KEYS.filter((key) => !seen.has(key));
const unquantified = ordered
.filter((part) => !part.quantified)
.map((part) => part.key);
const quantified = ordered.filter((part) => part.quantified).length;
return ok({
parts: ordered,
missing,
unquantified,
coveredBps: divide(quantified, CONTRACT_PART_KEYS.length),
complete: missing.length === 0 && unquantified.length === 0,
});
}
/** Remaining exposure after the insurer pays the covered amount. */
export function uninsuredLoss(
lossCents: Cents,
coverageCents: Cents,
deductibleCents: Cents,
): Cents {
const covered = Math.max(0, Math.min(lossCents, coverageCents) - deductibleCents);
return Math.max(0, lossCents - covered);
}
assessContract 返回 missing、unquantified 和 coveredBps。missing 是结构缺口,unquantified 是数字缺口,coveredBps 是两者的合并进度。LedgerLite 的八部分可以全部填满:当事人 240,240 美元、范围 240,240 美元、价格 9,873 美元、期限 240,240 美元、责任 240,240 美元、IP 0 美元、数据 1,000,000 美元、终止 22,000 美元。这里把不同性质的金额放在同一张表里,不是为了相加,而是为了确认每一部分都有一个可核对的数。
uninsuredLoss 处理尾部:损失先减去免赔额,再受保额限制,剩余部分就是自付。它不能替代保单条款,但能把「有保险」翻译成「最坏情况下我还需要准备多少现金」。对 LedgerLite,1,000,000 美元的保额不是「最多损失 1,000,000 美元」,而是「超过保额和免赔额的部分要自己承担」。
代码有三个刻意的边界。第一,八个部分按固定顺序返回,输入顺序不影响结果,这样模板检查才能稳定比较。第二,重复、未知、空说明的部分直接报错,不静默忽略;合同模板最常见的失败不是漏项,而是同一部分被两个版本重复引用。第三,0 和 null 分开,避免把「没有这项收入」和「还没算」混为一谈。
使用顺序建议是:先看 missing,把结构补全;再看 unquantified,把每个部分落到金额;最后看 coveredBps,它只是进度,不是质量。八个部分全填满,不代表条款对你有利;它只代表你已经知道每个部分会怎么改变财务数字。真正的谈判仍然要回到具体的上限、账期、验收条件和保险范围。
小结
- 合同是现金路径,不是法律文本;八个部分各自对应一个财务量。
- 当事人与主体决定钱能不能执行;范围与交付决定收入确认和成本上限。
- 价格与付款决定现金占用;期限与续约决定 ACV 能保多久。
- 责任与赔偿决定最坏损失;IP 决定资产能不能转移。
- 数据与保密决定出事后谁付钱;终止与争议决定退出成本。
- 八个部分不是等权,按生意形态分配审查精力,不要平均用力。
- 代码只检查结构和数字,不替代谈判;
null和0必须分开。 - 具体条款效力由适用法律决定,本节只算财务后果。
阅读导航:上一节:目录与学习路径 · 下一节:1.2 范围、交付与验收:把「做完」写成可判定条件 。
常见错误与边界
- 把合同当法律文本,只让律师看。 律师能告诉你条款是否可执行,不能告诉你 12 个月费用上限对应多少现金;财务后果要自己算。
- 只写合同金额,不写付款前提。 金额是分子,前提决定分子什么时候变成现金;没有前提的金额只是一个愿望。
- 把「12 个月费用」当成全公司上限。 它是每份合同的上限;260 家客户的上限不能简单相加成一次索赔,但可以用来估算量级。
- 把保险保额当成最坏损失上限。 保额之外还有免赔额、除外责任和未投保部分;保险只转移一部分尾部。
- 认为 IP 条款只影响技术。 它决定下一份合同能不能复用、公司出售时资产能不能转移,直接影响估值倍数。
- 把数据条款当成隐私政策。 DPA 决定通知时限、取证义务和赔偿路径,隐私政策只是对外说明;两者不能互相替代。
- 忽略终止后的过渡义务。 终止不是结束,未确认收入、退款、数据导出和交接都可能产生成本。
- 用一份合同代表全部客户。 LedgerLite 有 260 份合同,Nord 的每份咨询合同都不同;结构检查要跑在模板上,金额检查要跑在每一份上。
下一节把八个部分里最容易变形的「范围与交付」单独拿出来,说明验收标准如何决定收入确认时点和回款起点。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。