以下情境为教学假设,人物与数字不代表经核实的真实案例。
客户尽调不是把客户名单要过来看一眼。买方要回答四个问题:谁在付钱、这个人能不能走、合同能不能跟着交易走、价格能不能维持。四个问题里,前三个决定收入明年还在不在,最后一个决定收入还能不能按现在的价格收。对订阅业务,答案通常藏在 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,839 | 62,517 | 60.7% |
| Nord | 前 3 家 | 48% | 74,074 | 毛利 62,518 | 187,554 | 182.2% |
| LedgerLite | 最大一家 | 9% | 21,622 | 收入 21,622 | 69,190 | 9.0% |
| LedgerLite | 前 5 家 | 31% | 74,474 | 收入 74,474 | 238,318 | 31.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 技术与知识产权尽调 。
常见错误与边界
- 只看客户数量,不看收入集中度。 260 家客户听起来分散,前 5 家仍可能占 31%;买方看的是收入,不是 logo 数。
- 用平均份额代替最大客户。 Nord 的最大一家至少 16%,实际可能更高;用平均值会把风险算小。
- 把 ARR 倍数套到服务收入。 服务收入要先扣毛利再乘 SDE 倍数;直接乘 ARR 会高估价格影响。
- 忽略 change-of-control。 条款不一定写在「转让」里,可能藏在「终止」或「控制权变更」;抽样时要专门搜。
- 以为股权交易不用管合同。 主体不变,但控制权变了;CoC 条款仍可能被触发。
- 把续约率当成留存率。 70% 续约率是毛口径,不含新签和扩张;要和 GRR、NRR 一起看。
- 把折扣当成永久收入。 年付 15% 折扣如果到期后必须延续,提价空间就不存在;买方会按折扣后的价格估值。
- 用案例比例做客户结论。 16%、30.6%、5% 都是教学假设;真实数据要逐客户、逐合同核对。
本节的边界是「客户与合同如何改变价格」:不展开财务尽调、技术 IP 和交割迁移,也不对具体合同条款给法律意见。集中度的财务口径见 7.2 集中度风险 ,完整尽调清单见 12.1 尽调清单 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。