以下情境为教学假设,人物与数字不代表经核实的真实案例。
违约讨论最容易滑向两个极端:要么把它当成对方人品问题,要么把它当成一条抽象的法律概念。对独立开发者更有用的分类方式,是按「这笔违约最终要赔多少钱、由谁承担哪一部分」来分。同一份合同里,晚交两周、少做一个功能、明确说不做了、以及其中一个交付物因为第三方原因永远做不了,对应的金额算法完全不同。把它们混成一类,就会在谈判里既高估自己的筹码,也低估自己的敞口。
四种形态对应四套金额算法
先给结论:违约的形态决定了救济的起点,而救济的起点决定金额怎么算。迟延履行看时间,瑕疵履行看缺失范围,拒绝履行看替代成本,部分不能看受影响比例加已投入成本。四种形态可以同时出现,但同一笔损失不能重复计算。
| 形态 | 典型触发 | 首选救济 | 量化口径 | Nord 的 8,500 项目 |
|---|---|---|---|---|
| 迟延履行 | 里程碑晚交、验收拖期 | 违约金、催告后解除 | 合同额 × 周费率 × 周数,设上限 | 2 周 × 1% = 170 |
| 瑕疵履行 | 交付物不符合验收标准 | 修复、减价、替换 | 合同额 × 缺失范围比例 | 缺失 15% = 1,275 |
| 拒绝履行 | 明确表示不再履行 | 解除、替代履行差价 | (替代单价 − 原单价)× 剩余工作量 | 40 小时 × 25 = 1,000 |
| 部分不能 | 部分标的客观无法履行 | 按比例退款、减价 | 合同额 × 受影响比例 + 不可回收成本 | 30% 退款 2,550 + 成本 900 |
这张表里的数字都来自同一个 Nord 咨询项目:合同额 8,500 美元,按 85 美元/小时折算正好是 100 小时。合同额、单价和小时数三者互相可复算,后面每一节都只改其中一个变量,不引入第二套基准。
迟延履行:把时间写成可封顶的费率
迟延是最常见、也最容易被低估的违约。它的麻烦在于损失每天都在发生,但谈判时双方都不愿意承认一个「每天多少钱」的数字。合同里如果没有约定,事后就只能去证明实际损失,而实际损失往往比想象难证明:客户说发布会推迟了,但推迟带来的收入损失很难归因到这两周。
所以咨询合同通常直接约定违约金:合同额的一定比例,按周或按日累计,再设一个上限。Nord 的示例用每周 1%、上限 10%。两周延期就是:
违约金 = 8,500 × 1% × 2 = 170
上限 = 8,500 × 10% = 850
170 美元只占合同额的 2%,看起来不痛不痒。但它的作用不是惩罚,而是把「晚多久」变成一个双方都能提前计算的数字。如果没有这条,客户可能在第十周才说「因为你们晚了,整个项目失败」,那时争议金额就不是 170,而是整个项目的价值。
上限同样重要。没有上限的按周累计,在长项目里会迅速超过合同额本身。假设同一份合同晚了 12 周,按 1% 算是 12%,即 1,020 美元;有了 10% 上限,赔付停在 850 美元。上限是违约方愿意承担的最坏情况,也是守约方在签约时能接受的补偿范围。双方签的是一个可封顶的费率,不是一句「迟到要负责」。
迟延违约金和实际损失的关系要写清楚。如果违约金被当作对迟延损失的合理预估,就不能再就同一段迟延重复索赔实际损失;如果实际损失明显高于违约金,是否还能补足,取决于条款怎么写。本书不给法律意见,但从财务上要记住一条:同一笔损失只能收一次钱,条款要把「哪些损失归违约金、哪些另行计算」分开。
瑕疵履行:修复成本与减价金额取小
瑕疵履行不是「晚交」,而是「交了但不对」。它最常见的救济是修复或减价,两者选哪个,取决于修复成本和减价金额谁更低,以及客户是否还愿意等。
Nord 的项目里,100 小时中有 15 小时对应的功能没有交付。按缺失范围比例减价:
减价 = 8,500 × 15% = 1,275
如果 Nord 选择修复,假设需要 12 小时、按 85 美元/小时计,修复成本是 1,020 美元。1,020 小于 1,275,所以从违约方角度看,修复更便宜,差额 255 美元。从客户角度看,拿 1,275 的减价意味着永久失去这个功能;如果这个功能对上线是必需的,客户可能宁愿等修复,也不接受减价。
这就是瑕疵履行和迟延履行的关键区别:迟延可以用钱补,瑕疵不一定。减价的前提是「缺失的部分可以用价格衡量」,而有些功能缺失会让整个交付物失去用途,这时客户主张的就不是 15% 减价,而是更大范围的失败。验收标准写得越清楚,15% 这个比例越站得住;验收标准模糊,双方就会从「缺一个功能」吵到「整个项目算不算交付」。验收记录的作用见 1.2 范围、交付与验收:把「做完」写成可判定条件 。
修复还有时间成本。12 小时的修复不是 12 小时就能上线,它要排期、要回归测试、要客户重新验收。如果修复把项目再拖两周,迟延违约金可能同时触发。此时 1,020 的修复成本和 170 的迟延违约金会叠加,违约方真正付出的不是两者取小,而是修复成本加上新增的迟延。判断时要按「总现金流出」比较,而不是只比较减价和修复工时。
拒绝履行与部分不能:替代差价和沉没投入
拒绝履行是「明确不做了」,部分不能是「有一部分客观上做不了」。两者都涉及项目中断,但金额算法不同。
拒绝履行的核心是替代履行成本。客户已经付了前 60 小时,还剩 40 小时的工作;原单价 85 美元/小时,找另一家接手的报价是 110 美元/小时。客户的总成本从 8,500 变成:
已付 60 × 85 = 5,100
替代 40 × 110 = 4,400
合计 = 9,500
替代差价 = 9,500 − 8,500 = 1,000
客户能主张的是 1,000 的差价,而不是 4,400 的全部替代费用,因为那 40 小时本来也要付钱。已经付掉的 5,100 是不是「不可回收」,取决于已完成的工作对新承包方有没有用。如果新承包方必须推倒重来,5,100 就是沉没投入;如果代码和文档可以交接,它就还有残值。案例里把 5,100 列为客户侧的已投入不可回收成本,是为了提醒:替代差价只是账面上最容易算的那一部分,交接失败会让真实损失远高于 1,000。
部分不能则按比例走。假设项目里 30% 的交付物依赖一个即将停服的第三方接口,接口停服后这部分客观无法履行。按比例退款:
退款 = 8,500 × 30% = 2,550
但退款不是违约方的全部损失。为了这 30% 已经发生的外包成本 900 美元,如果合同没有把这部分成本转嫁给客户,它就只能自己承担。于是违约方的现金敞口是 2,550 + 900 = 3,450,而不是 2,550。部分不能最容易被忽略的就是这 900:退款是按合同额算的,已投入成本是按实际支出算的,两者口径不同,不能互相抵消。
拒绝履行和部分不能的边界,在于「能不能继续履行」。拒绝是主观选择,替代履行在市场上通常有报价;部分不能是客观障碍,替代履行可能根本不存在。前者可以用差价衡量,后者只能用比例退款加沉没成本衡量。把部分不能当成拒绝履行去主张全部替代成本,通常会高估可索赔金额。
叠加、抵扣与谈判区间
四种形态可以在同一项目里同时出现,但叠加有两条纪律。
第一条,不同损失可以并列,同一损失不能重复。Nord 的两周延期和 15% 功能缺失是两笔不同的损失:一个是时间,一个是范围。它们可以同时主张,合计 170 + 1,275 = 1,445,占 8,500 的 17%,客户实付 7,055。但如果客户既按 15% 减价,又要求违约方免费修复同一个功能,那就是把同一笔缺失收了两次。
第二条,抵扣要看现金流,不只看合同价。修复路线下,Nord 收到 8,500 − 170 = 8,330,支出修复成本 1,020,净现金 7,310。减价路线下,Nord 收到 8,500 − 170 − 1,275 = 7,055,不再支出修复成本,净现金 7,055。修复比减价多留 255 美元,正好等于 1,275 − 1,020。谈判区间就在 1,020 到 1,275 之间:低于 1,020,违约方宁愿修;高于 1,275,客户宁愿减价。
这张账还有第三项:如果修复导致项目再晚两周,迟延违约金再加 170,修复路线的净现金降到 7,140,仍然高于减价的 7,055,但差距缩到 85。若修复要拖一个月,迟延违约金可能触顶,修复就不再划算。判断「修还是赔」时,要把新增迟延一起算进去,而不是只比修复工时和减价金额。
LedgerLite 的对照在形态分布:企业订阅最常见的是 SLA 未达标,99.5% 可用性、抵扣 10%,按月费 77 美元算一次只抵 7.70;真正限制敞口的是 12 个月费用上限,单客户 924。订阅违约金额通常小到不值得进入程序。
写成代码:breach.ts
下面的 breach.ts 用一个联合类型表示四种形态,quantifyBreach 按 type 分派。每个分支只做一件事:把该形态的金额算出来,并返回 claimCents(守约方可以主张的金额)、performerExposureCents(违约方实际要付出的现金)和 route(支付、修复、减价或退款)。
// file: src/contract/breach.ts
// tier: L1
// verify: node --test test/contract/breach.test.ts
import { type Cents } from "../core/money.ts";
import { type Bps, applyRate } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";
export type BreachType =
| "delay"
| "defect"
| "refusal"
| "partial-impossibility";
export type BreachInput =
| {
type: "delay";
contractCents: Cents;
weeklyRateBps: Bps;
weeks: number;
capBps: Bps;
}
| {
type: "defect";
contractCents: Cents;
missingScopeBps: Bps;
cureHours: number;
hourlyCostCents: Cents;
}
| {
type: "refusal";
remainingHours: number;
originalHourlyCents: Cents;
replacementHourlyCents: Cents;
}
| {
type: "partial-impossibility";
contractCents: Cents;
affectedScopeBps: Bps;
nonRecoverableCostCents: Cents;
};
export type BreachResult = {
type: BreachType;
claimCents: Cents;
performerExposureCents: Cents;
route: "pay" | "cure" | "reduce" | "refund";
detail: string;
};
function rateError(value: Bps, label: string): string | null {
return Number.isInteger(value) && value >= 0 && value <= 10000
? null
: `${label} must be between 0 and 10000 bps`;
}
function amountError(value: Cents, label: string, positive = false): string | null {
const minimum = positive ? 1 : 0;
return Number.isInteger(value) && value >= minimum
? null
: `${label} must be ${positive ? "a positive" : "a non-negative"} integer amount in cents`;
}
/**
* Delay: contract x weekly rate x weeks, capped at a share of the contract.
* The cap makes the exposure knowable before the delay happens.
*/
function delay(input: Extract<BreachInput, { type: "delay" }>): Result<BreachResult> {
const error =
amountError(input.contractCents, "contract", true) ??
rateError(input.weeklyRateBps, "weekly rate") ??
rateError(input.capBps, "cap rate");
if (error) return fail(error);
if (!Number.isFinite(input.weeks) || input.weeks <= 0) {
return fail("weeks must be greater than zero");
}
const rawCents = Math.round(
applyRate(input.contractCents, input.weeklyRateBps) * input.weeks,
);
const capCents = applyRate(input.contractCents, input.capBps);
const claimCents = Math.min(rawCents, capCents);
return ok({
type: "delay",
claimCents,
performerExposureCents: claimCents,
route: "pay",
detail: rawCents > capCents ? "penalty capped" : "penalty as agreed",
});
}
/**
* Defect: the client's claim is the reduction for the missing scope; the
* performer pays whichever is cheaper, provided a cure is still acceptable.
*/
function defect(input: Extract<BreachInput, { type: "defect" }>): Result<BreachResult> {
const error =
amountError(input.contractCents, "contract", true) ??
rateError(input.missingScopeBps, "missing scope") ??
amountError(input.hourlyCostCents, "hourly cost");
if (error) return fail(error);
if (input.missingScopeBps === 0) {
return fail("missing scope must be greater than zero");
}
if (!Number.isFinite(input.cureHours) || input.cureHours < 0) {
return fail("cure hours must be a non-negative number");
}
const reductionCents = applyRate(input.contractCents, input.missingScopeBps);
const cureCostCents = Math.round(input.cureHours * input.hourlyCostCents);
const cureIsCheaper = cureCostCents <= reductionCents;
return ok({
type: "defect",
claimCents: reductionCents,
performerExposureCents: cureIsCheaper ? cureCostCents : reductionCents,
route: cureIsCheaper ? "cure" : "reduce",
detail: cureIsCheaper
? "curing costs less than the reduction"
: "the reduction is cheaper than curing",
});
}
/**
* Refusal: the client buys the remaining hours elsewhere and claims only the
* replacement premium, not the whole replacement invoice.
*/
function refusal(input: Extract<BreachInput, { type: "refusal" }>): Result<BreachResult> {
const error =
amountError(input.originalHourlyCents, "original hourly rate", true) ??
amountError(input.replacementHourlyCents, "replacement hourly rate", true);
if (error) return fail(error);
if (!Number.isFinite(input.remainingHours) || input.remainingHours <= 0) {
return fail("remaining hours must be greater than zero");
}
const premium = Math.max(
0,
input.replacementHourlyCents - input.originalHourlyCents,
);
const claimCents = Math.round(input.remainingHours * premium);
return ok({
type: "refusal",
claimCents,
performerExposureCents: claimCents,
route: "pay",
detail:
premium === 0
? "replacement is not more expensive"
: "cover is the replacement premium",
});
}
/**
* Partial impossibility: the affected scope is refunded, and cost already
* committed to that scope stays with the performer.
*/
function partial(
input: Extract<BreachInput, { type: "partial-impossibility" }>,
): Result<BreachResult> {
const error =
amountError(input.contractCents, "contract", true) ??
rateError(input.affectedScopeBps, "affected scope") ??
amountError(input.nonRecoverableCostCents, "non-recoverable cost");
if (error) return fail(error);
if (input.affectedScopeBps === 0) {
return fail("affected scope must be greater than zero");
}
const refundCents = applyRate(input.contractCents, input.affectedScopeBps);
return ok({
type: "partial-impossibility",
claimCents: refundCents,
performerExposureCents: refundCents + input.nonRecoverableCostCents,
route: "refund",
detail: "refund plus cost already committed to the affected scope",
});
}
export function quantifyBreach(input: BreachInput): Result<BreachResult> {
switch (input.type) {
case "delay":
return delay(input);
case "defect":
return defect(input);
case "refusal":
return refusal(input);
case "partial-impossibility":
return partial(input);
default: {
const exhaustive: never = input;
return fail(`unknown breach type: ${String(exhaustive)}`);
}
}
}
把 Nord 的数字换成美分代进去:迟延是 850,000 美分、每周 100 基点、2 周、上限 1,000 基点,返回 17,000 美分;瑕疵是 850,000 美分、缺失 1,500 基点、修复 12 小时 × 8,500 美分,返回主张 127,500 美分、敞口 102,000 美分、路线 cure;拒绝是剩余 40 小时、原价 8,500 美分、替代价 11,000 美分,返回 100,000 美分;部分不能是 850,000 美分、受影响 3,000 基点、不可回收 90,000 美分,返回主张 255,000 美分、敞口 345,000 美分。
代码只做单笔计算,不负责判断两种形态能不能叠加,也不处理违约金与实际损失的重复计算。这些是条款解释问题,不是算术问题;代码能保证的是,一旦你决定了按哪种形态主张,金额不会因为口径混乱而算错。
小结
- 违约按金额算法分四类:迟延看时间,瑕疵看缺失范围,拒绝看替代差价,部分不能看受影响比例加已投入成本。
- 违约金的公式是「合同额 × 周费率 × 周数」,上限决定了最坏情况;Nord 两周 170,触顶 850。
- 瑕疵履行的减价按缺失范围算:8,500 的 15% 是 1,275;修复成本 12 小时 × 85 = 1,020,修复更便宜 255。
- 拒绝履行的可索赔金额是替代差价,不是整张替代发票:40 小时 × 25 = 1,000。
- 部分不能按比例退款:30% 是 2,550;已投入且不可回收的 900 要另算,敞口 3,450。
- 不同损失可以并列,同一损失不能重复收钱;延期 170 加减价 1,275 是 1,445,实付 7,055。
- 修复路线净现金 7,310,减价路线 7,055;谈判区间在 1,020 到 1,275 之间。
- 代码只算单笔金额,叠加与抵扣要在调用前由条款口径决定。
阅读导航:上一节:4.3 对方破产或失联:能拿回多少 · 下一节:5.2 争议解决:管辖、仲裁与调解 。
常见错误与边界
- 把所有违约都当成「赔全款」。 四种形态的算法不同;拒绝履行也只赔替代差价,不是整张替代发票。
- 违约金不设上限。 按周累计在长项目里会超过合同额;Nord 12 周按 1% 是 1,020,没有 10% 上限就会赔穿。
- 把违约金和实际损失重复收。 同一段迟延只能收一次;条款要写清哪些损失归违约金。
- 减价比例拍脑袋。 15% 来自缺失范围占合同工作量的比例;没有验收标准,这个比例无法证明。
- 只比减价和修复工时。 修复还会带来新增迟延;要按总现金流出比较,而不是单项取小。
- 把替代发票全额当成损失。 客户本来也要付剩余工作的钱,可索赔的是替代单价与原单价的差价。
- 忽略已投入的沉没成本。 退款按合同额算,已发生成本按实际支出算;部分不能的敞口是两者相加。
- 把部分不能当成拒绝履行。 客观不能时可能没有替代市场,用差价法会高估可索赔金额。
- 让两种救济指向同一笔缺失。 既减价又要求免费修复同一个功能,是把同一损失收两次。
- 用代码替代条款判断。 代码只保证算术一致;能不能叠加、能不能补足,取决于合同怎么写。
本节的边界是「单笔违约的金额量化」:只覆盖迟延、瑕疵、拒绝和部分不能四类,不展开解除条件、过错程度、减损义务和跨境法律适用。所有费率、比例和替代报价均为教学假设;具体条款解释因司法辖区而异,本节不构成法律意见。争议进入程序后的成本比较见 5.2 争议解决:管辖、仲裁与调解 ,证据如何影响能否主张见 5.3 证据链与止损 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。