《独立开发者的商业指南·资本与退出》6.2 客户与合同尽调

客户尽调查四件事:集中度、合同可转让性、续约条款与定价可持续性。Nord 前 3 家占 48%,失去最大客户按教学假设会带走 60.7% 的估值;LedgerLite 最大一家 9%,影响正好是价格的 9%。本节把客户流失、change-of-control 与续约率算成价格和托管。

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

客户尽调不是把客户名单要过来看一眼。买方要回答四个问题:谁在付钱、这个人能不能走、合同能不能跟着交易走、价格能不能维持。四个问题里,前三个决定收入明年还在不在,最后一个决定收入还能不能按现在的价格收。对订阅业务,答案通常藏在 MRR 桥和标准合同里;对服务业务,答案藏在几个大客户的续约邮件和合伙人关系里。

Nord Studio 是本节的主案例。它的年收入是 154,320 美元,前 3 家客户占 48%,服务收入占 72.7%,两位合伙人各持 50%,买方按 3.0× SDE 给出的锚是 102,960 美元。LedgerLite 做对照:260 家付费客户,前 5 家占 31%,最大一家占 9%,3.2× ARR 的价格锚是 768,768 美元。两套客户结构对应两种完全不同的尽调问题:Nord 要问「这三家走了怎么办」,LedgerLite 要问「260 家里有多少家会在一年内走」。

四个检查:谁付钱、能不能走、合同能不能转、价格能不能维持

四个检查按风险从高到低排列。集中度决定单点失败的损失,可转让性决定交易能不能交割,续约条款决定收入曲线,定价可持续性决定未来的提价空间。买方通常先做集中度,再做合同抽样;如果集中度已经高到无法接受,后面的检查只是确认折价幅度。

检查买方的问题需要的证据失败后果价格机制
集中度前几家客户占多少收入客户收入表、合同台账、续约记录失去最大客户,收入断崖流失收入 × 毛利 × 倍数
可转让性换股东后合同还在不在合同转让条款、控制权条款consent 或 CoC 阻断交割托管、earn-out 或交割条件
续约条款到期后会不会续、多久前通知续约率、通知期、自动续约条款续约率低,收入不可持续按到期收入打折
定价可持续性折扣和涨价空间有多大报价单、折扣记录、MFN 条款提价即流失,折扣不能退出按折扣让利折算价格

四个检查里,集中度和可转让性最容易直接砍价,续约和定价更多影响倍数或 earn-out。下面的计算用教学假设的比例:Nord 的最大客户按前 3 家均分取 16%,这是下限,不是精确值;实际最大一家如果超过 16%,影响会更大。所有倍数、毛利率和比例都来自案例设定或由案例数字推导,不构成对真实客户的判断。

集中度:失去最大客户值多少

Nord 的前 3 家客户占 48%,按 154,320 美元计算是 74,074 美元收入。案例没有给出单家占比,教学假设按三家均分,最大一家至少占 16%,也就是 24,691 美元。Nord 的毛利率是 84.4%,失去这家客户,毛利减少 24,691 × 84.4% = 20,839 美元;按 3.0× SDE 计算,价格影响是 20,839 × 3.0 = 62,517 美元,相当于 102,960 美元锚的 60.7%。

如果前 3 家一起走,收入减少 74,074 美元,毛利减少 74,074 × 84.4% = 62,518 美元,按 3.0× 计算是 187,554 美元,已经超过整个价格锚 102,960 美元。这不是说买方会付负数,而是说在这个集中度下,3.0× SDE 的估值本身不成立;买方要么把价格压到远低于 3.0×,要么把大部分对价放进与客户留存挂钩的 earn-out。

案例客户口径收入份额流失收入估值基数价格影响占价格
Nord最大一家(均分下限)16%24,691毛利 20,83962,51760.7%
Nord前 3 家48%74,074毛利 62,518187,554182.2%
LedgerLite最大一家9%21,622收入 21,62269,1909.0%
LedgerLite前 5 家31%74,474收入 74,474238,31831.0%

LedgerLite 的最大一家占 9%,按 3.2× ARR 计算,价格影响是 21,622 × 3.2 = 69,190 美元,正好是 768,768 美元的 9%。前 5 家占 31%,如果一起走,价格影响是 31% × 768,768 = 238,318 美元。两个案例的差别不在集中度数字,而在估值基数:Nord 是 SDE 倍数,失去收入先扣毛利再乘倍数;LedgerLite 是 ARR 倍数,失去收入直接乘倍数。用错基数会把影响算大或算小。

合同可转让性:change-of-control 如何阻断交易

合同可转让性有三种常见状态。第一种是自由转让,买方接手公司或资产后合同继续有效;第二种是需要对方同意(consent),买方要逐家取得签字;第三种是控制权变更条款(change-of-control),一旦公司控制权变化,对方可以终止、重新谈价或要求额外保证。三种状态里,第三种最容易被卖方忽略,因为它不一定写在「转让」条款里,而是藏在「控制权变更」或「终止」条款里。

状态触发时点买方要做什么对交易的影响
自由转让交割后自动继续通知即可低,收入可承继
需要同意转让或交割时逐家取得书面同意中,交割条件可能完不成
控制权变更股权交易本身重新谈判或提供保证高,合同可能随交易终止

Nord 的前 3 家客户如果都带 consent 或 change-of-control 条款,48% 的收入就被挡住。按 3.0× 毛利计算,这部分收入对应的价格影响是 187,554 美元,超过 102,960 美元的锚。买方不会假装这条款不存在,而是把「取得三家书面同意」写成交割条件,或者把 48% 的对价放进托管和 earn-out。对卖方,这意味着交易可能因为一家客户不签字而失败,或者因为要重新谈价而少拿一大块。

LedgerLite 的对照结构不同。它有 260 家客户,单家收入小;买方可以用抽样、陈述保证和客户访谈处理,不需要逐家签字。即使个别企业合同需要同意,单家的收入影响也有限。这就是订阅业务和服务业务的差别:前者用客户分散降低单点同意风险,后者用几个大合同把风险集中到签字环节。股权收购和资产收购的差别见 7.1 资产收购与股权收购 :股权交易里主体不变,控制权条款仍可能被触发;资产交易里主体变了,转让条款直接适用。具体条款是否触发,要看合同文本和适用法律,本节不做法律判断。

续约与定价:合同里的小字

续约条款决定收入曲线。Nord 的咨询续约率是 70%,也就是每年有 30% 的咨询收入不续,按 112,200 美元计算是 33,660 美元。产品订阅的月流失是 3.0%,按 1 − 0.97¹² 计算,年流失约 30.6%,42,120 美元的产品收入里约 12,895 美元会在一年内流失。两项相加是 46,555 美元,占 154,320 美元的 30.2%。这个数字还没有算新签和扩张,所以它是毛流失口径,不是净收入变化。

LedgerLite 的月流失是 1.5%,年流失约 1 − 0.985¹² = 16.6%,按 240,240 美元计算是 39,848 美元。但它的 GRR 是 83%、NRR 是 102%,说明扩张收入抵消了部分流失;买方会把毛流失和净扩张分开看,不会只用 16.6% 乘 ARR。对订阅业务,续约条款的关键不是「自动续约」四个字,而是通知期、涨价权和折扣到期后的默认价格。

定价可持续性要看折扣和涨价空间。LedgerLite 的年付客户占 30%,年付折扣 15%;年付收入是 72,072 美元,折扣每多让 1 个百分点,约少收 721 美元/年。Nord 的咨询单价是 85 美元/小时;如果为了续约把费率下调 5%,年收入减少 112,200 × 5% = 5,610 美元,按 3.0× 计算是 16,830 美元的价格影响。5% 是教学假设,不是市场报价;它说明的是「用降价换续约」会把收入问题变成估值问题。

把检查写成代码:customer-dd.ts

下面的 customer-dd.ts 把三个检查写成函数。concentration 按客户收入排序,返回最大一家、前 3 家和前 5 家的占比;lostCustomerImpact 接受收入份额、毛利率、倍数和估值基数,返回流失收入、流失毛利和价格影响;assignabilityExposure 把合同分成自由转让、需要同意和控制权变更三类,返回被挡住的收入和占比。金额用整数美分,比例用基点。

// file: src/capital/customer-dd.ts
// tier: L1
// verify: node --test test/capital/customer-dd.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 CustomerShare = {
  name: string;
  revenueCents: Cents;
};

export type Concentration = {
  totalCents: Cents;
  customerCount: number;
  top1Cents: Cents;
  top1Bps: Bps;
  top3Bps: Bps;
  top5Bps: Bps;
};

function validCents(value: Cents): boolean {
  return Number.isInteger(value) && value >= 0;
}

function validBps(value: Bps): boolean {
  return Number.isInteger(value) && value >= 0 && value <= 10000;
}

/**
 * Concentration is measured on revenue, not on logo count. Ties keep the
 * input order so a report can explain why two customers with the same revenue
 * appear in a particular order.
 */
export function concentration(
  customers: CustomerShare[],
): Result<Concentration> {
  if (customers.length === 0) {
    return fail("at least one customer is required");
  }

  const seen = new Set<string>();
  let totalCents = 0;
  const rows: { row: CustomerShare; index: number }[] = [];
  for (const customer of customers) {
    if (customer.name.trim() === "") {
      return fail("customer name cannot be empty");
    }
    if (seen.has(customer.name)) {
      return fail(`duplicate customer ${customer.name}`);
    }
    if (!validCents(customer.revenueCents)) {
      return fail(
        `${customer.name} revenue must be a non-negative integer amount`,
      );
    }
    seen.add(customer.name);
    totalCents += customer.revenueCents;
    rows.push({ row: customer, index: rows.length });
  }
  if (totalCents === 0) {
    return fail("total customer revenue must be positive");
  }

  const ordered = rows
    .sort(
      (a, b) =>
        b.row.revenueCents - a.row.revenueCents || a.index - b.index,
    )
    .map((entry) => entry.row);

  const topN = (n: number): Cents =>
    ordered
      .slice(0, n)
      .reduce((acc, customer) => acc + customer.revenueCents, 0);

  const top1Cents = topN(1);

  return ok({
    totalCents,
    customerCount: rows.length,
    top1Cents,
    top1Bps: divide(top1Cents, totalCents),
    top3Bps: divide(topN(3), totalCents),
    top5Bps: divide(topN(5), totalCents),
  });
}

export type PriceBasis = "revenue" | "gross-profit";

export type LostCustomerInput = {
  annualRevenueCents: Cents;
  revenueShareBps: Bps;
  grossMarginBps: Bps;
  multipleBps: Bps;
  anchorPriceCents: Cents;
  basis: PriceBasis;
};

export type LostCustomerImpact = {
  lostRevenueCents: Cents;
  lostGrossProfitCents: Cents;
  priceImpactCents: Cents;
  impactBpsOfPrice: Bps;
};

/**
 * A revenue multiple loses revenue; an SDE multiple loses gross profit. The
 * two bases are kept explicit because using the wrong one overstates the cut
 * for a service business with subcontractors.
 */
export function lostCustomerImpact(
  input: LostCustomerInput,
): Result<LostCustomerImpact> {
  if (!Number.isInteger(input.annualRevenueCents) || input.annualRevenueCents <= 0) {
    return fail("annual revenue must be a positive integer amount");
  }
  if (!validBps(input.revenueShareBps)) {
    return fail("revenue share must be between 0 and 100%");
  }
  if (!validBps(input.grossMarginBps)) {
    return fail("gross margin must be between 0 and 100%");
  }
  if (!Number.isInteger(input.multipleBps) || input.multipleBps < 0) {
    return fail("multiple must be a non-negative integer in basis points");
  }
  if (
    !Number.isInteger(input.anchorPriceCents) ||
    input.anchorPriceCents <= 0
  ) {
    return fail("anchor price must be a positive integer amount");
  }
  if (input.basis !== "revenue" && input.basis !== "gross-profit") {
    return fail("basis must be revenue or gross-profit");
  }

  const lostRevenueCents = applyRate(
    input.annualRevenueCents,
    input.revenueShareBps,
  );
  const lostGrossProfitCents = applyRate(
    lostRevenueCents,
    input.grossMarginBps,
  );
  const basisCents =
    input.basis === "revenue" ? lostRevenueCents : lostGrossProfitCents;
  const priceImpactCents = Math.round(
    (basisCents * input.multipleBps) / 10000,
  );

  return ok({
    lostRevenueCents,
    lostGrossProfitCents,
    priceImpactCents,
    impactBpsOfPrice: divide(priceImpactCents, input.anchorPriceCents),
  });
}

export type AssignmentTerm = "free" | "consent" | "change-of-control";

export type ContractTerm = {
  id: string;
  annualRevenueCents: Cents;
  assignment: AssignmentTerm;
};

export type AssignabilityExposure = {
  totalCents: Cents;
  blockedCents: Cents;
  blockedBps: Bps;
  blockedCount: number;
  totalCount: number;
};

/**
 * Consent and change-of-control both block a clean transfer. They differ in
 * timing: consent is asked at assignment, while a change-of-control clause can
 * be triggered by the deal itself even when the contract stays with the same
 * legal entity.
 */
export function assignabilityExposure(
  contracts: ContractTerm[],
): Result<AssignabilityExposure> {
  if (contracts.length === 0) {
    return fail("at least one contract is required");
  }

  const seen = new Set<string>();
  let totalCents = 0;
  let blockedCents = 0;
  let blockedCount = 0;

  for (const contract of contracts) {
    if (contract.id.trim() === "") {
      return fail("contract id cannot be empty");
    }
    if (seen.has(contract.id)) {
      return fail(`duplicate contract ${contract.id}`);
    }
    if (!validCents(contract.annualRevenueCents)) {
      return fail(`${contract.id} revenue must be a non-negative integer amount`);
    }
    if (
      contract.assignment !== "free" &&
      contract.assignment !== "consent" &&
      contract.assignment !== "change-of-control"
    ) {
      return fail(`unknown assignment term ${contract.assignment}`);
    }
    seen.add(contract.id);
    totalCents += contract.annualRevenueCents;
    if (contract.assignment !== "free") {
      blockedCents += contract.annualRevenueCents;
      blockedCount += 1;
    }
  }

  if (totalCents === 0) {
    return fail("total contract revenue must be positive");
  }

  return ok({
    totalCents,
    blockedCents,
    blockedBps: divide(blockedCents, totalCents),
    blockedCount,
    totalCount: contracts.length,
  });
}

把 Nord 代进去:前 3 家是 4800 个基点,最大一家按均分下限是 1600 个基点;失去最大一家的价格影响是 6,251,811 美分(约 62,518 美元),占 10,296,000 美分的 6072 个基点;前 3 家一起走是 18,755,436 美分(约 187,554 美元),占 18216 个基点。LedgerLite 最大一家是 900 个基点,价格影响 6,918,912 美分(约 69,189 美元),正好 900 个基点;前 5 家是 3100 个基点。合同可转让性把 Nord 的前 3 家标成 consent 或 change-of-control,就得到 4800 个基点的 blocked revenue。

代码里有一个数据处理上的假设:为了让 concentration 按客户逐行计算,测试把「其余客户」拆成若干等额小客户;真实数据应该逐客户输入,不能把 52% 的尾部当成一家。这个假设不影响前 3 家的占比,但会影响最大一家的判断,所以正文用「均分下限」而不是精确值。

小结

  • 客户尽调做四个检查:集中度、合同可转让性、续约条款、定价可持续性。
  • Nord 前 3 家占 48%,失去最大一家按均分下限 16% 计算,价格影响 62,517 美元,占 102,960 美元的 60.7%。
  • Nord 前 3 家一起走,价格影响 187,554 美元,超过整个价格锚;3.0× SDE 在这个集中度下不成立。
  • LedgerLite 最大一家占 9%,价格影响 69,190 美元,正好是 768,768 美元的 9%;前 5 家 31% 对应 238,318 美元。
  • consent 和 change-of-control 都能阻断交割;Nord 的 48% 收入如果都要同意,买方会把它写成交割条件或托管。
  • Nord 的咨询续约率 70%、产品月流失 3.0%,一年毛流失约 46,555 美元,占收入 30.2%。
  • 定价可持续性要看折扣和涨价权;Nord 降价 5% 换续约,按 3.0× 是 16,830 美元的价格影响。
  • 所有份额、比例和倍数都是教学假设或案例设定;真实客户数据要逐户核对。

阅读导航:上一节:6.1 财务尽调 · 下一节:6.3 技术与知识产权尽调 。

常见错误与边界

  1. 只看客户数量,不看收入集中度。 260 家客户听起来分散,前 5 家仍可能占 31%;买方看的是收入,不是 logo 数。
  2. 用平均份额代替最大客户。 Nord 的最大一家至少 16%,实际可能更高;用平均值会把风险算小。
  3. 把 ARR 倍数套到服务收入。 服务收入要先扣毛利再乘 SDE 倍数;直接乘 ARR 会高估价格影响。
  4. 忽略 change-of-control。 条款不一定写在「转让」里,可能藏在「终止」或「控制权变更」;抽样时要专门搜。
  5. 以为股权交易不用管合同。 主体不变,但控制权变了;CoC 条款仍可能被触发。
  6. 把续约率当成留存率。 70% 续约率是毛口径,不含新签和扩张;要和 GRR、NRR 一起看。
  7. 把折扣当成永久收入。 年付 15% 折扣如果到期后必须延续,提价空间就不存在;买方会按折扣后的价格估值。
  8. 用案例比例做客户结论。 16%、30.6%、5% 都是教学假设;真实数据要逐客户、逐合同核对。

本节的边界是「客户与合同如何改变价格」:不展开财务尽调、技术 IP 和交割迁移,也不对具体合同条款给法律意见。集中度的财务口径见 7.2 集中度风险 ,完整尽调清单见 12.1 尽调清单 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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