以下情境为教学假设,人物与数字不代表经核实的真实案例。
尽调报告里的「红旗」(red flag)不是骂人,也不是说生意有问题。它指的是买方在材料里发现的一个需要解释的信号:客户太集中、创始人太关键、应收太旧、合同不能转、口径变过。信号本身不决定交易成败,决定成败的是卖方能不能在买方形成最坏假设之前,把事实、原因、修复动作和证据一次讲清。
Nord Studio 是这一节的中心。它的收入是 154,320 美元,服务占 72.7%,前 3 家客户 48%,两位合伙人各 50%,没有书面退出条款,应收逾期 8%。这些数字放在一起,买方的第一反应不是「这家公司有问题」,而是「我买到的收入明年还在不在」。LedgerLite 作为对照:前五大客户 31%、最大一家 9%、逾期 6%,集中度和逾期都存在,但因为客户数 260、MRR 桥可还原、合同是标准订阅文本,买方会把它当成可管理的风险,而不是致命缺陷。Tidecast 的 Steam 80% 只在平台与 IP 条款处出现。
红旗不是坏消息,是无法解释的坏消息
买方对风险的定价方式很朴素:能验证的风险按概率折价,不能验证的风险按最坏情况折价。前者可以谈,后者没有上限。所以同一件事,主动披露并给出证据,和等买方自己发现,价格可能差很多。
以 Nord 的客户集中为例。前 3 家客户合计 48%,这个数字本身是事实。如果卖方只给一张总收入表,买方会假设「这 48% 随时可能归零」,然后在估值上体现最坏情况。如果卖方给出三家客户的合同起止日、续约记录、交付依赖和替换计划,买方仍然会折价,但折价有边界:他能看到哪些收入有合同保护、哪些需要重新获客、需要多长时间。
再以 LedgerLite 的逾期为例。逾期 30 天以上占应收 6%,坏账准备 1%。这个比例不算低,但它的应收只有 12,000 美元,约 18 天收入,账期 Net 15,客户数 260。买方会问「哪几家逾期、催收做到哪一步」,而不会直接把它当成收入质量问题。区别不在 6% 这个数字,而在背后的结构能不能被解释。
红旗管理的第一步不是修复,而是分类:哪些能在交割前修好,哪些只能披露,哪些会改变交易结构。修得好的,变成加分项;修不好的,至少要有诚实的解释和可执行的补救计划。
七类典型红旗与买方问法
下面七类问题在独立开发者交易里出现频率最高。表中的「折价方向」只描述买方可能采取的动作,不是百分比;具体幅度取决于规模、增长、买方战略和谈判,不能从本文推出。
| 红旗 | 买方会怎么问 | 诚实回答的骨架 | 折价方向 |
|---|---|---|---|
| 收入集中在单一客户或平台 | 前 3 家 48%,明年还会续吗;Steam 80%,平台改条款怎么办 | 承认集中;给合同到期日、续约记录、迁移或替代计划 | 压低倍数、提高托管、拉长 earn-out |
| 创始人不可替代 | 你走了,客户还签吗;交付谁来做 | 列出分工、交接记录、接替成本与招聘计划 | 要求留任协议、关键人保险、earn-out |
| 应收逾期 | 逾期 8% 什么时候收回;坏账准备够不够 | 按客户列账龄、催收记录、坏账政策 | 下调营运资金、现金托管 |
| 关联交易 | 你和公司之间有没有未披露的资金往来 | 给关联方台账、定价依据、清理时间表 | 要求剥离或补偿、特别赔偿 |
| 口径中途变化 | 为什么去年 MRR 和今年对不上 | 说明旧口径、新口径、重述金额与影响期间 | 按保守口径重算、延后定价 |
| 合同缺转让条款 | 交割后客户会不会走;要不要逐家同意 | 给合同原文、同意函计划、逐家时间表 | 把部分对价转成 earn-out 或托管 |
| 数据合规缺口 | 客户数据有没有 DPA、删除机制、泄露记录 | 给数据地图、DPA、删除记录、修复计划 | 特别赔偿、交割前条件、托管 |
还有一类常被忽略:收入确认与现金错位。利润表上有利润,银行账上没现金,可能是因为递延收入、应收增加或平台结算周期。买方会问「利润去哪了」,回答要能落到资产负债表和现金流量表,而不是「客户还没付款」。这类问题不一定是红旗,但解释不清就会变成红旗。
七类问题里,前两类最难修。客户集中和创始人依赖是生意结构,不是补一份文件就能解决;应收、关联交易、口径、合同条款和数据合规,大多可以在交割前通过清理、补充协议或披露解决。卖方应该先处理能修的,再把不能修的讲清楚,而不是反过来。
诚实回答的四个部分
一个能降低折价的回答,通常包含四段:事实、原因、修复、证据。缺一段,买方就会用自己的假设补上。
| 层次 | 要写什么 | 反例 |
|---|---|---|
| 事实 | 具体数字、期间、口径、涉及对象 | 「客户比较集中,但还好」 |
| 原因 | 为什么会形成,是选择还是遗留 | 「行业都这样」 |
| 修复 | 已经做了什么,接下来做什么,谁负责 | 「以后会注意」 |
| 证据 | 合同、台账、系统导出、会议记录 | 「你可以问我」 |
以 Nord 的服务收入为例。事实:服务收入 112,200 美元,占总收入 72.7%,咨询续约率 70%,产品月流失 3.0%。原因:公司从服务起家,产品线是后加的,销售资源优先给咨询。修复:把产品订阅作为第二条收入线,目标客户从咨询客户中筛选;招聘第三人的成本是 55,000 美元/年,需要新增收入至少 75,000 美元才能覆盖工资与税。证据:咨询合同、工时记录、产品订阅导出、续约邮件。
以 Nord 的合伙人结构为例。事实:两位合伙人各 50%,没有期权池,没有书面退出条款。原因:成立时以项目合作为主,没有做股权安排。修复:在交割前签署股东协议,明确退出、买断、竞业和知识转移;案例事实表给出的退出报价是 50% × 4.5 × SDE = 77,220 美元,分 24 个月支付,可以作为谈判参照,但不是唯一答案。证据:股东协议草案、董事会决议、历史分红记录。
以 Tidecast 的平台依赖为例。事实:Steam 占销量 80%,净得 138,171 美元,净得率 72.0%。原因:买断制游戏需要平台流量,官网只占 20%。修复:提高官网占比,保留平台协议与结算单,准备 IP 授权路径;案例事实表给出的授权报价是保底 60,000 美元/年 + 8% 净收入,3 年,最低保证 180,000 美元。证据:平台协议、月度结算单、官网支付流水、音乐授权合同。
以 LedgerLite 的集中度为例。事实:前五大客户 31%,最大一家 9%,客户数 260,月流失 1.5%。原因:早期靠少数大客户起量,后来长尾变厚。修复:建立前五名续约日历,按季度跟踪;不需要应急方案,但需要持续披露。证据:客户收入表、MRR 桥、续约记录。这个回答之所以有效,是因为它没有否认集中,而是把集中放进一个可验证的结构里。
诚实不等于把所有细节倒给买方。回答的边界是「与估值和交易结构相关的事实」:客户是谁、合同多久、收入怎么确认、依赖谁、如果失去会怎样。与交易无关的个人信息、其他客户的商业机密、内部政治,不需要也不应该进入回答。
折价的相对关系:三个案例
三个案例的估值锚不同,不能直接用倍数高低判断谁被压价更多。LedgerLite 用 ARR,Tidecast 用收入,Nord 用 SDE;先把锚说清,再谈折价。
| 案例 | 主要红旗 | 买方的第一反应 | 估值锚与相对位置 |
|---|---|---|---|
| LedgerLite | 前五大 31%、最大 9%、逾期 6% | 验证流失与续约,不构成致命缺陷 | 3.2 × ARR = 768,768 美元;经常性收入支撑倍数 |
| Tidecast | Steam 80%、平台协议、音乐不可转授权 | 压平台风险,但 IP 可单独授权 | 1.8 × 收入 = 248,708 美元;倍数低于订阅型 |
| Nord Studio | 服务 72.7%、两位合伙人、前 3 家 48%、逾期 8% | 怀疑收入可转移性,要求留任或改结构 | 标准 4.5 × SDE = 154,440;买方 3.0 × SDE = 102,960 |
Nord 的 4.5 × SDE 到 3.0 × SDE,差额是 51,480 美元,相当于标准锚的 33.3%。这是案例事实表给出的整体折价,不是某一面红旗的单独定价。把它拆成「客户集中扣多少、合伙人依赖扣多少、逾期扣多少」没有依据;买方通常是把所有可转移性问题打包,给出一个他认为能承受的价格。卖方能做的是把每个问题的事实和修复讲清,让这个打包价有下限。
Tidecast 的 1.8 × 收入低于 LedgerLite 的 3.2 × ARR,但不能简单说「平台依赖导致 1.8 倍」。买断制没有经常性收入,估值锚本来就不同;平台依赖影响的是倍数和交易结构,IP 授权路径则提供了另一种退出方式。LedgerLite 的 3.2 × ARR 也不是「没有红旗」,它的前五大 31% 和逾期 6% 都在,只是经常性收入和客户数让买方愿意按收入倍数谈。
三个案例的相对关系可以总结为一句话:LedgerLite 的红旗是「可管理的集中」,Tidecast 的红旗是「可授权的平台依赖」,Nord 的红旗是「不可转移的服务收入」。三种红旗对应三种交易结构:前者正常交割,中者可能拆成 IP 授权或整体出售两条路,后者需要留任、earn-out 或合伙人退出安排。折价方向由此而来,而不是由某一个百分比决定。
把解释写成可核查的材料
红旗管理不能停在口头。每一面红旗都应该有一条记录:规则、严重度、证据、解释、负责人、修复状态。下面的 red-flags.ts 把这件事固化成模型:DdFacts 收集九类事实,RED_FLAG_RULES 定义八条规则,detectRedFlags 返回命中的红旗,highestSeverity 给出整体严重度。每条命中都带 evidence(字段名、基点和百分比)和 explanation(解释模板),方便直接贴进尽调回复。
// file: src/value/red-flags.ts
// tier: L1
// verify: node --test test/value/red-flags.test.ts
import type { Cents } from "../core/money.ts";
import { type Bps, pct } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";
export type RedFlagSeverity = "low" | "medium" | "high";
export type DdFacts = {
largestCustomerShareBps: Bps;
topThreeCustomerShareBps: Bps;
topPlatformShareBps: Bps;
keyPersonRevenueShareBps: Bps;
overdueReceivableBps: Bps;
relatedPartyCents: Cents;
restatedPeriods: number;
contractsAssignable: boolean;
personalDataGaps: number;
};
export type RedFlagRule = {
id: string;
title: string;
severity: (facts: DdFacts) => RedFlagSeverity;
evaluate: (facts: DdFacts) => string | null;
explain: (facts: DdFacts) => string;
};
export type RedFlagFinding = {
ruleId: string;
title: string;
severity: RedFlagSeverity;
evidence: string;
explanation: string;
};
const SEVERITY_ORDER: RedFlagSeverity[] = ["low", "medium", "high"];
export const RED_FLAG_RULES: RedFlagRule[] = [
{
id: "customer-concentration",
title: "revenue concentrated in a few customers",
severity: (facts) =>
facts.largestCustomerShareBps >= 3000 ||
facts.topThreeCustomerShareBps >= 6000
? "high"
: "medium",
evaluate: (facts) =>
facts.largestCustomerShareBps >= 2000 ||
facts.topThreeCustomerShareBps >= 4000
? `largestCustomerShareBps=${facts.largestCustomerShareBps} (${pct(facts.largestCustomerShareBps)}), topThreeCustomerShareBps=${facts.topThreeCustomerShareBps} (${pct(facts.topThreeCustomerShareBps)})`
: null,
explain: () =>
"A buyer will test renewal, termination and assignment terms before trusting next year's revenue.",
},
{
id: "platform-dependence",
title: "revenue depends on one platform",
severity: (facts) =>
facts.topPlatformShareBps >= 7500 ? "high" : "medium",
evaluate: (facts) =>
facts.topPlatformShareBps >= 5000
? `topPlatformShareBps=${facts.topPlatformShareBps} (${pct(facts.topPlatformShareBps)})`
: null,
explain: () =>
"Platform terms, fees and reach can change unilaterally; the buyer will ask for the agreement and a migration path.",
},
{
id: "key-person",
title: "revenue follows the founder",
severity: () => "high",
evaluate: (facts) =>
facts.keyPersonRevenueShareBps >= 5000
? `keyPersonRevenueShareBps=${facts.keyPersonRevenueShareBps} (${pct(facts.keyPersonRevenueShareBps)})`
: null,
explain: () =>
"If relationships and delivery depend on one person, the buyer is buying a resigned employee, not an asset.",
},
{
id: "overdue-receivables",
title: "receivables are past due",
severity: (facts) => {
if (facts.overdueReceivableBps >= 2400) return "high";
if (facts.overdueReceivableBps >= 1200) return "medium";
return "low";
},
evaluate: (facts) =>
facts.overdueReceivableBps >= 600
? `overdueReceivableBps=${facts.overdueReceivableBps} (${pct(facts.overdueReceivableBps)} of receivables)`
: null,
explain: () =>
"Past-due invoices are a cash and quality problem at once; the buyer will re-age the ledger and may hold back cash.",
},
{
id: "related-party",
title: "transactions with related parties",
severity: () => "high",
evaluate: (facts) =>
facts.relatedPartyCents > 0
? `relatedPartyCents=${facts.relatedPartyCents}`
: null,
explain: () =>
"Related-party flows must be disclosed and normalized; undisclosed ones turn into a warranty claim.",
},
{
id: "restatement",
title: "accounting policy changed mid-period",
severity: (facts) => (facts.restatedPeriods >= 2 ? "high" : "medium"),
evaluate: (facts) =>
facts.restatedPeriods > 0
? `restatedPeriods=${facts.restatedPeriods}`
: null,
explain: () =>
"A restated period breaks comparability; the buyer will rebuild the series on one policy before pricing it.",
},
{
id: "non-assignable-contracts",
title: "customer contracts cannot be transferred",
severity: () => "high",
evaluate: (facts) =>
facts.contractsAssignable ? null : "contractsAssignable=false",
explain: () =>
"Without assignment or change-of-control language, consent is needed customer by customer and revenue can leave at closing.",
},
{
id: "personal-data-gap",
title: "personal data handling has gaps",
severity: (facts) => (facts.personalDataGaps >= 3 ? "high" : "medium"),
evaluate: (facts) =>
facts.personalDataGaps > 0
? `personalDataGaps=${facts.personalDataGaps}`
: null,
explain: () =>
"Missing records of processing, retention or deletion turn a compliance question into an indemnity request.",
},
];
function isBps(value: number): boolean {
return Number.isInteger(value) && value >= 0 && value <= 10000;
}
function validateFacts(facts: DdFacts): Result<true> {
const shares: Array<[string, number]> = [
["largestCustomerShareBps", facts.largestCustomerShareBps],
["topThreeCustomerShareBps", facts.topThreeCustomerShareBps],
["topPlatformShareBps", facts.topPlatformShareBps],
["keyPersonRevenueShareBps", facts.keyPersonRevenueShareBps],
["overdueReceivableBps", facts.overdueReceivableBps],
];
for (const [name, value] of shares) {
if (!isBps(value)) {
return fail(`${name} must be an integer between 0 and 10000`);
}
}
if (!Number.isInteger(facts.relatedPartyCents) || facts.relatedPartyCents < 0) {
return fail("relatedPartyCents must be a non-negative integer");
}
if (!Number.isInteger(facts.restatedPeriods) || facts.restatedPeriods < 0) {
return fail("restatedPeriods must be a non-negative integer");
}
if (!Number.isInteger(facts.personalDataGaps) || facts.personalDataGaps < 0) {
return fail("personalDataGaps must be a non-negative integer");
}
return ok(true);
}
export function detectRedFlags(facts: DdFacts): Result<RedFlagFinding[]> {
const checked = validateFacts(facts);
if (!checked.ok) {
return checked;
}
const findings: RedFlagFinding[] = [];
for (const rule of RED_FLAG_RULES) {
const evidence = rule.evaluate(facts);
if (evidence === null) {
continue;
}
findings.push({
ruleId: rule.id,
title: rule.title,
severity: rule.severity(facts),
evidence,
explanation: rule.explain(facts),
});
}
return ok(findings);
}
export function highestSeverity(
findings: RedFlagFinding[],
): RedFlagSeverity | "none" {
let highest: RedFlagSeverity | "none" = "none";
for (const finding of findings) {
if (
highest === "none" ||
SEVERITY_ORDER.indexOf(finding.severity) >
SEVERITY_ORDER.indexOf(highest)
) {
highest = finding.severity;
}
}
return highest;
}
代码有四个刻意的设计。第一,严重度不是固定值,而是随事实变化:逾期 600 基点是低,1,200 基点是中,2,400 基点是高;重述一期是中,两期是高。第二,证据字符串同时保留原始基点和百分比,避免买方只看到四舍五入后的数字。第三,规则只覆盖可量化事实,关联交易、合同转让和数据合规用布尔值或计数表达,不假装能自动判断法律结论。第四,highestSeverity 返回最差一档,不返回平均;红旗是风险指标,平均会把高风险稀释掉。
实际使用时,每季度跑一次,把命中的红旗分给责任人,附上证据和修复计划。对买方披露时,先说事实,再说修复,最后给证据;不要先讲「行业都这样」。如果一面红旗在交割前修不好,就把它写进交易结构:托管、earn-out、特别赔偿或交割前条件。写进去的代价通常小于被买方自己发现。
小结
- 红旗不是坏消息,无法解释的红旗才是;买方对不能验证的风险按最坏情况定价。
- 七类高频红旗:客户或平台集中、创始人不可替代、应收逾期、关联交易、口径变化、合同不可转让、数据合规缺口;收入与现金错位是第八类。
- 诚实回答分四段:事实、原因、修复、证据;缺一段,买方就会用自己的假设补上。
- Nord 的核心红旗是服务收入 72.7% 与两位合伙人依赖,前 3 家 48% 和逾期 8% 是表现,不是全部原因。
- 折价方向包括压低倍数、提高托管、拉长 earn-out、要求留任或特别赔偿;具体幅度取决于交易,不能从案例百分比外推。
- Nord 从 4.5 × SDE 到 3.0 × SDE 的整体折价是 33.3%,这是案例设定的总折价,不要拆成单面红旗的定价。
- 可核查的红旗记录包含规则、严重度、证据、解释、负责人和修复状态;能修的在交割前修好,修不好的写进交易结构。
阅读导航:上一节:12.2 数据室与三年对账 · 下一节:13.1 三张表的横向对照 。
常见错误与边界
- 把红旗当成道德问题。 红旗是风险信号,不是欺诈指控;否认或回避只会让买方按最坏情况定价。
- 只给结论不给证据。 「客户关系很好」不是回答;合同、续约记录、回款流水才是。
- 用行业惯例搪塞。 「行业都集中」不减少这家公司的具体风险;买方买的是这一家,不是行业平均。
- 把整体折价拆成单面红旗的百分比。 Nord 的 33.3% 是服务依赖与合伙人结构的整体结果,拆开没有依据。
- 隐瞒关联交易。 关联交易可以披露和清理,隐瞒会变成陈述与保证违约,代价更高。
- 口径变化不重述。 改了收入确认或成本归类却不重算历史,买方会发现前后不可比,进而怀疑所有数字。
- 合同缺转让条款却承诺收入。 没有 assignment 或 change-of-control 条款,交割后客户可以合法离开;承诺前先取得同意函。
- 把数据合规当成法务的事。 数据地图、DPA、删除记录是业务事实,财务和产品都要能解释;只交给律师,回答会空。
- 修复计划没有负责人和日期。 「正在改进」不是计划;写清谁在什么时候完成什么,买方才会把它计入价格。
红旗处理的终点不是「没有问题」,而是「问题被看见、被解释、被定价」。下一章把三个案例的三张表横向放在一起,回到数字本身,检查前面的判断能不能在报表上闭合。
集中度的结构可以回看 集中度风险 ,估值锚与谈判区间可以回看 11.3 估值区间与谈判区间 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。