《独立开发者的商业指南·合同、应收与风险》1.2 范围、交付与验收:把「做完」写成可判定条件

验收不是一句「做完」,而是一组可判定的条件。本节把范围、交付与验收拆成收入确认、回款起点和履约成本上限三个财务后果,给出模糊验收与可判定验收的对照,并计算验收延迟 30 天和 60 天对 Nord Studio 与 LedgerLite 现金流的影响。

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

「做完」不是一个事件,而是一个判断。合同里写「完成开发并通过验收」,等于把判断权交给了客户:客户可以今天确认,也可以下个月确认;可以因为一个按钮的颜色不确认,也可以因为预算周期不确认。这个选择权有价格,价格就是被延迟的现金。

Nord Studio 的咨询合同把这个问题放大了。它按 85 美元/小时、每月 110 小时计费,咨询年收入 112,200 美元,公司年收入 154,320 美元;账期 Net 30,付款前提是验收;应收占年收入 7.3%,约 11,265 美元,折合 27 天;逾期率 8%,坏账准备 2%;SDE 只有 34,320 美元。如果所有回款都因验收晚 30 天,上限口径的占用是 12,684 美元,相当于全年 SDE 的 37.0%;只算受验收影响的咨询收入,则是 9,222 美元,占 26.9%。这不是项目管理的延迟,而是财务上的结构性占用。

LedgerLite 的结构不同。它卖的是订阅,收入按月确认,账期 Net 15;年末应收 12,000 美元,约 18 天收入;SDE 130,240 美元。订阅也需要上线和确认,但确认通常发生在单个客户的开通环节,不会把全部 260 家客户的回款绑在同一个验收动作上。同样的 30 天延迟,对 LedgerLite 是 19,746 美元,占 SDE 的 15.2%;绝对金额更大,但相对承受力更强。

验收为什么是财务条款

验收至少改变三个财务量:收入确认时点、回款起点、履约成本上限。三者经常被混为一谈,但它们的触发条件不同,延迟一天的影响也不同。

财务后果触发条件延迟一天的影响谁承担
收入确认交付完成、验收通过或服务期间流逝收入晚一天进入利润表卖方
回款起点发票开出、验收通过或里程碑达成现金晚一天到账,应收多一天卖方
履约成本返工、修改、补充交付成本多一天人工或外包卖方
争议成本双方对「是否完成」有不同理解谈判、取证、律师费增加双方,但卖方先垫现金

对订阅生意,收入确认通常跟着服务期间走,验收的影响主要在开通和回款;对咨询和定制项目,验收是收入确认和开票的共同前提,影响最大。Nord 属于后者:验收不通过,收入不能确认,发票也不能开,现金时钟停在交付之后、开票之前。

验收和交付也不是一回事。交付是「把东西交出去」,验收是「确认东西符合条件」。合同只写交付,客户可以说「收到了,但还没验收」;合同写清验收条件,客户再说「没做完」就必须指出哪一条没通过。前者是把争议留给关系,后者是把争议落到清单。

最关键的一条:验收标准必须是第三方可以复核的。如果只有客户能判断「满意不满意」,验收就不是标准,而是客户的选择权。选择权在客户手里,现金时钟也在客户手里。

模糊验收与可判定验收的对照

下面的对照不是为了把合同写得更长,而是为了把「做完」拆成可以逐项检查的条件。表中的具体阈值是演示用占位值,真实合同应按项目填写;本节只讨论财务后果,不提供法律意见。

维度模糊写法可判定写法财务差异
完成完成网站开发12 个页面全部交付,每页含默认、空、错误三种状态范围可数,履约成本有上限
质量符合客户要求24 项检查清单全部通过,检查人和日期写在验收单上返工次数可约束
性能性能良好指定网络条件下首屏不超过 2.5 秒,连续 3 次达标与否可复现
数据数据准确抽样 200 条,字段一致率不低于 99.5%,未解释差异为 0验收不靠感觉
时间尽快上线交付后 5 个工作日内书面确认,逾期未提出书面异议视为通过现金时钟有终点
修改免费修改到满意含 2 轮修改,超出按变更单计费成本上限可算
付款验收后付款每个模块验收后 10 个工作日内支付该模块的 30%回款分批,不被整体卡住

模糊写法的问题不是不严谨,而是把判定权交给了对方。「满意」没有第三方可复核的标准,客户可以一直不满意,现金时钟就一直不走。可判定写法不一定对卖方更有利,但它把争议从「做没做完」变成「哪一项没通过」,后者可以修、可以计价、可以分批回款。

可判定验收还要写清三件事:谁验收、用什么证据、多久不回复算通过。缺一个,标准就会在项目结束时重新谈判。验收人最好写角色而不是姓名,避免负责人离职后条款失效;证据最好是验收单、测试报告或带时间戳的截图,而不是聊天记录里的「看起来可以」;视为通过的天数要写进合同,不能靠催。

分模块验收是另一个降低财务风险的办法。一个 12 页的网站如果只在全部完成后验收一次,任何一个页面争议都会卡住全部回款;如果按 3 个模块验收、每个模块单独开票,争议只影响一个模块的金额。分模块会增加管理成本,但它把「全有或全无」变成了「部分确定」。

修改轮次上限则控制履约成本。合同写「免费修改到满意」,等于把返工成本写成无限;写「含 2 轮修改,超出按变更单计费」,返工就有了价格。对一人公司,返工不是加班就能解决,它会直接挤掉下一个项目的收入。

验收延迟 30 天与 60 天的现金影响

验收延迟的现金影响可以用一个简单公式估算:延迟现金 = 计算基数 × 延迟天数 / 365。下表用全部年收入作为上限口径,假设所有回款都晚同样的天数;如果只有部分项目延迟,按延迟项目的收入加权即可。

案例年收入(上限口径)日均收入延迟 30 天占年收入延迟 60 天占年收入
Nord Studio154,320 美元423 美元12,684 美元8.2%25,368 美元16.4%
LedgerLite240,240 美元658 美元19,746 美元8.2%39,492 美元16.4%

百分比是通用的:任何业务延迟 30 天,都会多占用 8.2% 的计算基数;延迟 60 天,多占用 16.4%。美元金额随基数放大,但承受力要看 SDE。Nord 的 12,684 美元相当于 SDE 34,320 美元的 37.0%,60 天则是 73.9%;LedgerLite 的 19,746 美元相当于 SDE 130,240 美元的 15.2%,60 天是 30.3%。

Nord 的上限口径包含了产品订阅收入,但产品是按月计费,不受项目验收影响。如果只按受验收影响的咨询收入 112,200 美元计算,30 天的影响是 9,222 美元,占 SDE 的 26.9%;60 天是 18,444 美元,占 53.7%。真实影响更接近后者;上限口径仍然有用,因为它给出了「所有回款都被验收卡住」的最坏情况。LedgerLite 的 19,746 美元同样是全部订阅回款的上限;如果只算新签客户的开通延迟,基数更小。

更重要的是,验收延迟通常不是一次性的。如果所有回款都因验收晚 30 天,企业会永久多占用 30 天收入;这不是某个月的现金流波动,而是营运资金的结构性增加。要恢复原来的现金水平,要么提高收入,要么缩短验收,要么用外部资金补上这 30 天。对没有融资的一人公司,第三条通常不存在。

账龄表看不到这段等待。Nord 的应收是年收入 7.3%、27 天,看起来比 Net 30 还快;但验收发生在开票之前,交付到验收的 30 天不在应收里。真实现金周期 = 验收等待 + 开票后回款 = 30 + 27 = 57 天。合同只写 Net 30,会让人误以为 30 天就能回款,实际要多等一个验收周期。

LedgerLite 的账龄更接近合同:Net 15,实际 18 天,差 3 天。这 3 天来自开票批次和银行转账,不是验收等待。订阅生意的现金周期短,不是因为客户更守信用,而是因为验收被拆散到每个客户的开通环节,没有被集中成一个项目级门槛。

把验收写成可执行判定

一条可执行的验收条件至少有四个部分:对象、阈值、证据、时限。对象是「检查什么」,阈值是「达到多少算过」,证据是「谁来记录、记录在哪里」,时限是「多久不回复算通过」。四者缺一,条件就会在项目收尾时重新解释。

验收条件可以分成两类。第一类是二值条件:有或无、通过或不通过,例如「12 个页面全部交付」「隐私政策页面上线」。第二类是阈值条件:有数字和方向,例如「首屏不超过 2.5 秒」「字段一致率不低于 99.5%」「错误率不高于 0.5%」。二值条件适合范围和存在性,阈值条件适合性能、质量和数据准确性。两类条件混在一张验收单上,判定才不会变成主观讨论。

下面的 scope.ts 把这两类条件固化成模型。evaluateScope 接收条件列表,逐条判定,返回通过数、通过率和阻塞项;acceptanceCashImpact 把延迟天数换算成被占用的现金和占年收入的比例。

// file: src/contract/scope.ts
// tier: L1
// verify: node --test test/contract/scope.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 BinaryCriterion = {
  id: string;
  kind: "binary";
  description: string;
  met: boolean;
};

export type ThresholdCriterion = {
  id: string;
  kind: "threshold";
  description: string;
  actual: number;
  target: number;
  direction: "atLeast" | "atMost";
};

export type Criterion = BinaryCriterion | ThresholdCriterion;

export type CriterionResult = {
  id: string;
  description: string;
  passed: boolean;
};

export type ScopeAssessment = {
  results: CriterionResult[];
  passed: number;
  total: number;
  passedBps: Bps;
  accepted: boolean;
  blocking: string[];
};

export type AcceptanceImpact = {
  annualRevenueCents: Cents;
  delayDays: number;
  dailyRevenueCents: Cents;
  delayedCashCents: Cents;
  shareOfYearBps: Bps;
};

function isFiniteNumber(value: number): boolean {
  return typeof value === "number" && Number.isFinite(value);
}

function checkThreshold(criterion: ThresholdCriterion): boolean {
  return criterion.direction === "atLeast"
    ? criterion.actual >= criterion.target
    : criterion.actual <= criterion.target;
}

/**
 * Evaluate written acceptance criteria. The function does not decide whether
 * a customer is satisfied; it only checks whether the criteria in the
 * contract are met. A single failed criterion blocks acceptance, because a
 * partial pass is exactly where "done" becomes negotiable again.
 */
export function evaluateScope(criteria: Criterion[]): Result<ScopeAssessment> {
  if (criteria.length === 0) {
    return fail("scope needs at least one criterion");
  }
  const seen = new Set<string>();
  const results: CriterionResult[] = [];
  for (const criterion of criteria) {
    if (criterion.id.trim() === "") {
      return fail("criterion id must not be empty");
    }
    if (seen.has(criterion.id)) {
      return fail(`duplicate criterion id: ${criterion.id}`);
    }
    if (criterion.description.trim() === "") {
      return fail(`criterion ${criterion.id} needs a description`);
    }
    let passed: boolean;
    if (criterion.kind === "binary") {
      if (typeof criterion.met !== "boolean") {
        return fail(`criterion ${criterion.id} needs a boolean result`);
      }
      passed = criterion.met;
    } else if (criterion.kind === "threshold") {
      if (
        !isFiniteNumber(criterion.actual) ||
        !isFiniteNumber(criterion.target)
      ) {
        return fail(`criterion ${criterion.id} needs finite numbers`);
      }
      if (
        criterion.direction !== "atLeast" &&
        criterion.direction !== "atMost"
      ) {
        return fail(`criterion ${criterion.id} has an unknown direction`);
      }
      passed = checkThreshold(criterion);
    } else {
      return fail(`criterion ${criterion.id} has an unknown kind`);
    }
    seen.add(criterion.id);
    results.push({
      id: criterion.id,
      description: criterion.description,
      passed,
    });
  }

  const passed = results.filter((result) => result.passed).length;
  return ok({
    results,
    passed,
    total: results.length,
    passedBps: divide(passed, results.length),
    accepted: passed === results.length,
    blocking: results
      .filter((result) => !result.passed)
      .map((result) => result.id),
  });
}

/**
 * Cash delayed by acceptance is proportional to the delay, not to the work
 * completed. A 30-day acceptance lag on every invoice permanently ties up
 * 30/365 of annual revenue in the business.
 */
export function acceptanceCashImpact(
  annualRevenueCents: Cents,
  delayDays: number,
): Result<AcceptanceImpact> {
  if (!Number.isInteger(annualRevenueCents) || annualRevenueCents <= 0) {
    return fail("annual revenue must be a positive integer amount in cents");
  }
  if (!Number.isInteger(delayDays) || delayDays < 0) {
    return fail("delay days must be a non-negative integer");
  }
  const dailyRevenueCents = Math.round(annualRevenueCents / 365);
  const delayedCashCents = Math.round(
    (annualRevenueCents * delayDays) / 365,
  );
  return ok({
    annualRevenueCents,
    delayDays,
    dailyRevenueCents,
    delayedCashCents,
    shareOfYearBps: divide(delayedCashCents, annualRevenueCents),
  });
}

evaluateScope 有三个刻意的设计。第一,所有条件都通过才算验收通过,部分通过仍然是阻塞;如果业务上允许分模块验收,应该把每个模块建成独立的验收单,而不是让同一张单子部分通过。第二,重复编号、空描述、非有限数字都会报错;验收单最常见的失败不是条件太严,而是同一个条件被两个版本重复引用。第三,阻塞项按输入顺序返回,方便直接对应到验收单的行号。

acceptanceCashImpact 把延迟变成现金。它不判断延迟是否合理,只回答「如果所有回款晚 N 天,企业要多占用多少现金」。对 Nord,30 天是 12,684 美元,60 天是 25,368 美元;对 LedgerLite,30 天是 19,746 美元,60 天是 39,492 美元。代码返回的 shareOfYearBps 在 30 天时是 822 个基点,60 天时是 1644 个基点,与上表的 8.2% 和 16.4% 对应。

代码不能替代合同谈判,但它能防止一种常见错误:把「验收后付款」当成一句没有成本的描述。只要把验收天数代进去,这句话立刻变成一个现金数字。数字出来之后,再决定要不要接受这个条款,比在项目结束时才发现回款被卡住要好。

小结

  • 验收是财务条款,不是项目收尾动作;它同时决定收入确认、回款起点和履约成本上限。
  • 模糊验收把判定权交给客户,现金时钟停在客户手里;可判定验收把争议落到具体条件。
  • 一条可执行的条件要有对象、阈值、证据、时限;缺一个,标准就会重新谈判。
  • 验收延迟 N 天,等于永久多占用 N/365 的年收入;30 天是 8.2%,60 天是 16.4%。
  • Nord 的 30 天延迟:全部收入上限口径是 12,684 美元,占 SDE 37.0%;咨询收入口径是 9,222 美元,占 26.9%。
  • Nord 的 60 天延迟:上限口径是 25,368 美元,占 SDE 73.9%;咨询收入口径是 18,444 美元,占 53.7%。
  • LedgerLite 的 30 天延迟是 19,746 美元,占 SDE 的 15.2%;订阅把验收拆散,承受力更强。
  • 账龄表只统计开票后,验收等待不在其中;真实现金周期要加上验收时间。
  • 分模块验收、修改轮次上限、视为通过条款,都是把验收变成可计算量的方法。

阅读导航:上一节:1.1 一份商业合同的八个部分 · 下一节:1.3 价格、调价与付款条款:金额、时点、前提与违约责任 。

常见错误与边界

  1. 把交付当验收。 交付只证明东西发出去了,验收才确认符合条件;合同只写交付,客户可以说「收到了,但还没验收」。
  2. 验收标准只写在邮件里。 邮件不是合同附件,项目换人或换邮箱就找不到;验收条件必须和合同放在一起。
  3. 没有视为通过条款。 客户不回复不等于通过;没有时限,验收可以无限期悬空,现金时钟也无限期停着。
  4. 全部验收才付款。 一个大项目只在最后验收一次,任何一个小争议都会卡住全部回款;分模块验收能把影响限制在一个模块。
  5. 修改次数没有上限。 「免费修改到满意」把返工成本写成无限;含几轮修改、超出怎么计费,必须写清楚。
  6. 验收人没有写进合同。 只写「客户验收」,没有角色和权限;负责人离职或换岗,验收单可能没人能签。
  7. 把 AR 账龄当成完整现金周期。 账龄只统计已开票未收回,验收等待在开票之前;Nord 的 27 天应收不等于 27 天回款。
  8. 用「满意」「符合预期」做标准。 这些词没有第三方可复核的含义;争议时只能回到谈判,不能回到清单。
  9. 认为验收条件越多越好。 100 项清单会推高维护成本,也会给客户更多拖延理由;抓住影响回款和成本的必过项更重要。
  10. 把验收延迟当成一次性波动。 如果验收总是晚 30 天,所有回款都会跟着晚,企业会永久多占用 30 天收入;这是营运资金结构问题,不是单月现金流问题。

下一节把验收之后的动作接上:验收通过只是回款起点,金额、时点、前提和违约责任才决定现金什么时候真正到账。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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