以下情境为教学假设,人物与数字不代表经核实的真实案例。
财务尽调不是把审计再做一遍。审计回答报表是否符合准则,尽调回答买方付钱之后还能不能收到同样的钱。买方不会按你的科目表提问,而是按五个核对拆收入、应收、费用、关联交易和税务;每一个核对都要落到一份文件、一笔回款或一条合同条款上。找不到对应证据的部分,最终都会变成价格调整,或者更常见的托管。
Nord Studio 是本节的主案例。它的年收入是 154,320 美元,SDE 是 34,320 美元,买方按 3.0× SDE 给出的锚是 102,960 美元。LedgerLite 做对照:年收入 240,240 美元,应收 12,000 美元,3.2× ARR 的价格锚是 768,768 美元。两个案例的账都不复杂,复杂的是同一项发现落在不同分母上,价格影响差一个量级。
五个核对:从发现到价格
五个核对不是五张问卷,而是一条价格链:先确认事实,再决定调整基数,最后选择直接扣减还是按倍数放大。买方通常先做收入与应收,因为这两项决定估值分子;费用、关联交易和税务更多影响 SDE 与交割后的负债。
| 核对 | 买方要验证 | 典型证据 | 发现问题时 | 价格机制 |
|---|---|---|---|---|
| 收入确认 | 已确认收入是否有履约对应 | 合同、发票、递延收入滚动表 | 提前确认、一次性收入计入 MRR | 直接调减收入,或按倍数放大 |
| 应收质量 | 应收能否按账面收回 | 账龄表、催收记录、坏账准备 | 逾期高、准备不足 | 托管或营运资金调整 |
| 费用归属 | 成本是否属于本期、是否属于公司 | 发票、工时、验收单、分摊表 | 跨期成本、个人费用混入 | 调减 SDE,按倍数放大 |
| 关联交易 | 交易是否按市价、是否可持续 | 关联方清单、合同、银行流水 | 高于市价、依赖关联方 | 调增成本,按倍数放大 |
| 税务合规 | 申报与计提是否完整 | 申报表、完税凭证、税费计提表 | 少计提、跨境漏报 | 直接扣减,或预留税款 |
直接扣减和倍数放大是两种不同的谈判语言。直接扣减是一美元对一美元,买方容易接受,卖方也容易解释;倍数放大是把一年的影响乘以 3.0× SDE 或 3.2× ARR,金额更大,但买方必须先证明这项影响会持续。下面的 3.0× 与 3.2× 来自案例的估值锚,10%、5%、25% 等比例是教学假设,不是行业标准,也不是对任何真实交易的判断。
收入确认:5% 的口径差,12,012 美元
LedgerLite 的年收入是 240,240 美元。如果买方发现其中 5% 没有对应的履约期间,而是提前确认的年付、一次性上线费或被当成订阅的用量补差,收入基数就要下调 240,240 × 5% = 12,012 美元。这个数字本身是收入调整,不是最终价格影响;买方可以按两种方式使用它。
| 口径 | 收入调整 | 价格影响 | 适用场景 |
|---|---|---|---|
| 直接调减 | 12,012 | 12,012 | 卖方接受等额扣减,倍数不动 |
| 按 3.2× ARR 重算 | 12,012 | 38,438 | 买方重算 ARR 后重新乘倍数 |
12,012 与 38,438 的差别,就是谈判的空间。卖方如果坚持 3.2× 倍数,买方会把 12,012 从 ARR 里拿掉再乘,价格影响放大到 12,012 × 3.2 = 38,438 美元;卖方如果接受等额扣减,倍数保住,价格只少 12,012 美元。两种写法在合同里都常见,关键是把基数、倍数和调整方式写在同一张表上,而不是只写一个总数。
买方要的证据不是一句解释,而是能复算的滚动表。LedgerLite 的递延收入是 22,000 美元,MRR 桥是期初 19,280 加新签 780、扩张 310、减收缩 60、减流失 290,期末 20,020。年付客户占 30%,年付折扣 15%;如果年付收入被提前确认,递延余额和 MRR 桥会对不上,买方一眼就能看到。Nord 的咨询收入按小时计费,没有订阅式递延,但它需要另一张桥:期初在手工时加新签工时减完成工时,期末未开票工时就是收入确认的缺口。
应收质量:2% 坏账准备如何触发托管
Nord 的应收占年收入 7.3%,按 154,320 美元计算约 11,265 美元。账龄表显示逾期 30 天以上占 8%,坏账准备是 2%;两项相加,买方认为这笔应收的风险是 10%。这个比例超过教学假设的 8% 重大性阈值,买方不会只在价格上扣 10%,而是要求把整笔应收放进托管,等回款后再释放。
| 案例 | 应收 | 逾期 | 坏账准备 | 风险合计 | 托管方式 |
|---|---|---|---|---|---|
| Nord | 11,265 | 901(8%) | 225(2%) | 10% | 全额 11,265,6 个月 |
| LedgerLite | 12,000 | 720(6%) | 120(1%) | 7% | 风险部分 840,3 个月 |
Nord 的直接价格影响看起来只有 11,265 × 10% = 1,127 美元,但托管金额是 11,265 美元。两者的区别很重要:折价是永久的,托管是暂时的;如果应收在 6 个月内收回,托管款退给卖方,如果收不回,买方才从托管里扣。买方用托管而不是折价,是因为应收质量差不一定等于坏账,但它一定等于不确定性。
LedgerLite 的应收是 12,000 美元,逾期 6%、准备 1%,风险合计 7%,低于阈值,所以只托管风险部分 840 美元,期限 3 个月。这个差异不是 LedgerLite 的客户更讲信用,而是它的账期是 Net 15、应收只相当于 18 天收入;Nord 的账期是 Net 30、应收约 27 天收入,逾期和准备都更高。营运资金调整也会跟着变:LedgerLite 的目标营运资金是 15,000 美元,实际 12,000 美元,买方会在价格里下调 3,000 美元,这部分见 5.3 估值调整项:现金、负债、营运资金 。
费用归属、关联交易与税务合规
后三个核对不像收入那样直接改变估值分子,但它们会改变 SDE,而 SDE 是 3.0× 倍数的基数。费用归属看的是成本有没有跨期、有没有把合伙人个人支出混进公司;关联交易看的是交易价格是否偏离市价;税务合规看的是申报和计提有没有缺口。
| 核对 | 案例 | 基数 | 调整 | 倍数 | 价格影响 |
|---|---|---|---|---|---|
| 费用归属 | Nord | 外包 18,000 | 25% = 4,500 | 3.0× | 13,500 |
| 关联交易 | Nord | 关联外包 6,000 | 高于市价 20% = 1,200 | 3.0× | 3,600 |
| 税务合规 | LedgerLite | 所得税 11,941 | 少计提 10% = 1,194 | 直接 | 1,194 |
Nord 的外包成本是 18,000 美元。按教学假设,买方抽查发票和验收单后发现其中 25%(4,500 美元)属于跨期项目,按权责发生制应计入下一年度;当年的 SDE 因此被高估 4,500 美元,按 3.0× 计算,价格下调 13,500 美元。这不是舞弊,而是小团队常见的记账时点问题;买方不会因为金额小就忽略,因为同一套记账习惯会影响其他期间。
关联交易是另一个容易被低估的核对。Nord 的 18,000 美元外包里,按教学假设有 6,000 美元付给其中一位合伙人持有的供应商;买方按市价比较后认为高了 20%,也就是每年多付 1,200 美元。把 1,200 美元加回成本,SDE 下调 1,200 美元,按 3.0× 是 3,600 美元的价格影响。金额不大,但它暴露的是治理问题:买方会追问另外一位合伙人是否知情、合同有没有竞标、未来会不会继续。
税务合规的调整通常是直接的。LedgerLite 的所得税示例是 11,941 美元;按教学假设,如果买方发现少计提 10%,就是 1,194 美元的负债,直接从价格里扣。LedgerLite 是新加坡主体,GST 9%,海外客户还要判断注册门槛;Nord 是英国 Ltd,VAT 20%,公司税 25%,示例税负 8,580 美元。税率和申报义务随辖区与年度变化,案例数字只用于演示结构,真实交易必须按当期法规核对。
把可折价的项目放在一起,Nord 的价格调整合计(不含应收托管)是 13,500 + 3,600 = 17,100 美元,占 102,960 美元的 16.6%;LedgerLite 是 12,012 + 840 + 1,194 = 14,046 美元,占 768,768 美元的 1.8%。同样的核对,绝对金额相差不大,相对影响差了九倍。小交易的卖方尤其要注意:一项 1 万美元的发现,在大交易里是零头,在 10 万美元的交易里就是两位数的百分比。
把调整写成代码:dd.ts
下面的 dd.ts 把发现、价格影响和应收托管写成三个函数。priceImpact 接收基数、调整比例和倍数,先算年度影响,再算价格影响;summarizeImpacts 校验编号、按类别汇总,并返回调整后的价格;receivablesEscrow 把逾期与坏账准备合成风险比例,按重大性阈值决定全额托管还是部分托管。金额全部用整数美分,比例全部用基点。
// file: src/capital/dd.ts
// tier: L1
// verify: node --test test/capital/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 FindingCategory =
| "revenue-recognition"
| "receivables"
| "expense-attribution"
| "related-party"
| "tax";
export const FINDING_ORDER: FindingCategory[] = [
"revenue-recognition",
"receivables",
"expense-attribution",
"related-party",
"tax",
];
/**
* A finding turns a due-diligence observation into a price adjustment. The
* base is the amount the observation applies to, the adjustment is the share
* of that base at issue, and the multiple converts an annual impact into an
* enterprise-value impact. A direct adjustment uses a multiple of 10000 bps,
* which is 1x.
*/
export type Finding = {
id: string;
category: FindingCategory;
label: string;
baseCents: Cents;
adjustmentBps: Bps;
multipleBps: Bps;
};
export type FindingImpact = {
id: string;
category: FindingCategory;
label: string;
annualImpactCents: Cents;
multipleBps: Bps;
priceImpactCents: Cents;
};
export type CategoryImpact = {
category: FindingCategory;
priceImpactCents: Cents;
shareBps: Bps;
};
export type ImpactSummary = {
rows: FindingImpact[];
totalPriceImpactCents: Cents;
priceCents: Cents;
adjustedPriceCents: Cents;
totalBpsOfPrice: Bps;
byCategory: CategoryImpact[];
};
export type ReceivablesInput = {
arCents: Cents;
overdueBps: Bps;
reserveBps: Bps;
materialityBps: Bps;
fullEscrowMonths: number;
partialEscrowMonths: number;
};
export type EscrowPlan = {
overdueCents: Cents;
reserveCents: Cents;
riskBps: Bps;
fullEscrow: boolean;
escrowCents: Cents;
months: number;
};
function isCategory(value: string): value is FindingCategory {
return (FINDING_ORDER as string[]).includes(value);
}
function validCents(value: Cents): boolean {
return Number.isInteger(value) && value >= 0;
}
function validBps(value: Bps): boolean {
return Number.isInteger(value) && value >= 0 && value <= 10000;
}
/**
* One finding becomes one row. The annual impact is rounded before the
* multiple is applied, so the same inputs always produce the same cents.
*/
export function priceImpact(finding: Finding): Result<FindingImpact> {
if (finding.id.trim() === "") {
return fail("finding id cannot be empty");
}
if (!isCategory(finding.category)) {
return fail(`unknown category ${finding.category}`);
}
if (finding.label.trim() === "") {
return fail(`${finding.id} needs a label`);
}
if (!validCents(finding.baseCents)) {
return fail(`${finding.id} base must be a non-negative integer amount`);
}
if (!validBps(finding.adjustmentBps)) {
return fail(`${finding.id} adjustment must be between 0 and 100%`);
}
if (
!Number.isInteger(finding.multipleBps) ||
finding.multipleBps < 10000 ||
finding.multipleBps > 100000
) {
return fail(`${finding.id} multiple must be between 1x and 10x`);
}
const annualImpactCents = applyRate(
finding.baseCents,
finding.adjustmentBps,
);
const priceImpactCents = Math.round(
(annualImpactCents * finding.multipleBps) / 10000,
);
return ok({
id: finding.id,
category: finding.category,
label: finding.label,
annualImpactCents,
multipleBps: finding.multipleBps,
priceImpactCents,
});
}
/**
* Findings are grouped by category so a seller can see whether the cut comes
* from revenue, working capital, cost normalization or tax. The total is not
* capped: an adjustment larger than the price is a signal that the deal does
* not work at this anchor, not a number to hide.
*/
export function summarizeImpacts(
findings: Finding[],
priceCents: Cents,
): Result<ImpactSummary> {
if (!Number.isInteger(priceCents) || priceCents <= 0) {
return fail("price must be a positive integer amount in cents");
}
if (findings.length === 0) {
return fail("at least one finding is required");
}
const seen = new Set<string>();
const rows: FindingImpact[] = [];
for (const finding of findings) {
if (seen.has(finding.id)) {
return fail(`duplicate finding ${finding.id}`);
}
const impact = priceImpact(finding);
if (!impact.ok) {
return impact;
}
seen.add(finding.id);
rows.push(impact.value);
}
const totalPriceImpactCents = rows.reduce(
(acc, row) => acc + row.priceImpactCents,
0,
);
const byCategory: CategoryImpact[] = [];
for (const category of FINDING_ORDER) {
const inCategory = rows.filter((row) => row.category === category);
if (inCategory.length === 0) {
continue;
}
const categoryImpact = inCategory.reduce(
(acc, row) => acc + row.priceImpactCents,
0,
);
byCategory.push({
category,
priceImpactCents: categoryImpact,
shareBps: divide(categoryImpact, totalPriceImpactCents),
});
}
return ok({
rows,
totalPriceImpactCents,
priceCents,
adjustedPriceCents: priceCents - totalPriceImpactCents,
totalBpsOfPrice: divide(totalPriceImpactCents, priceCents),
byCategory,
});
}
/**
* Receivables are treated as collected only after they clear. The materiality
* threshold and both holding periods are teaching assumptions: when overdue
* plus reserve reaches the threshold, the buyer holds the whole balance; below
* it, the buyer holds only the at-risk portion. Either way the money is
* escrowed, not deducted from the headline price.
*/
export function receivablesEscrow(
input: ReceivablesInput,
): Result<EscrowPlan> {
if (!Number.isInteger(input.arCents) || input.arCents <= 0) {
return fail("accounts receivable must be a positive integer amount");
}
if (!validBps(input.overdueBps)) {
return fail("overdue share must be between 0 and 100%");
}
if (!validBps(input.reserveBps)) {
return fail("reserve share must be between 0 and 100%");
}
if (!validBps(input.materialityBps)) {
return fail("materiality threshold must be between 0 and 100%");
}
if (input.overdueBps + input.reserveBps > 10000) {
return fail("overdue plus reserve cannot exceed the whole balance");
}
if (
!Number.isInteger(input.fullEscrowMonths) ||
input.fullEscrowMonths <= 0
) {
return fail("full escrow months must be a positive integer");
}
if (
!Number.isInteger(input.partialEscrowMonths) ||
input.partialEscrowMonths <= 0
) {
return fail("partial escrow months must be a positive integer");
}
const overdueCents = applyRate(input.arCents, input.overdueBps);
const reserveCents = applyRate(input.arCents, input.reserveBps);
const riskBps = input.overdueBps + input.reserveBps;
const fullEscrow = riskBps >= input.materialityBps;
return ok({
overdueCents,
reserveCents,
riskBps,
fullEscrow,
escrowCents: fullEscrow ? input.arCents : overdueCents + reserveCents,
months: fullEscrow ? input.fullEscrowMonths : input.partialEscrowMonths,
});
}
把 LedgerLite 的三项代进去:收入调整 1,201,200 美分、应收 84,000 美分、税务 119,410 美分,合计 1,404,610 美分,占 76,876,800 美分的 183 个基点,调整后价格 75,472,190 美分。Nord 的两项是 1,350,000 + 360,000 = 1,710,000 美分,占 10,296,000 美分的 1661 个基点。托管函数对 Nord 返回风险 1000 个基点、全额 1,126,500 美分、6 个月;对 LedgerLite 返回风险 700 个基点、部分 84,000 美分、3 个月。这些输出和上面的表逐行对应。
代码的边界是「已量化的调整」。它不判断收入确认是否合规,不计算真实税率,也不替代合同审查;8% 的重大性阈值、6 个月和 3 个月的托管期都是教学假设。它只保证一件事:每一项发现都能追到基数、比例、倍数和最终价格,卖方在谈判桌上才能把「我觉得问题不大」换成「这项调整值多少」。
小结
- 财务尽调做五个核对:收入确认、应收质量、费用归属、关联交易、税务合规。
- 收入确认差 5% 对应 240,240 × 5% = 12,012 美元;按 3.2× ARR 重算会放大到 38,438 美元。
- Nord 应收约 11,265 美元,逾期 8% 加坏账准备 2% 达到 10%,触发全额托管 11,265 美元、6 个月。
- 费用归属的 4,500 美元跨期成本按 3.0× 放大为 13,500 美元;关联交易高于市价 1,200 美元放大为 3,600 美元。
- Nord 的可折价调整合计(不含应收托管)17,100 美元,占 102,960 美元的 16.6%;LedgerLite 合计 14,046 美元,占 768,768 美元的 1.8%。
- 折价是永久的,托管是暂时的;应收质量差通常先触发托管,而不是直接砍价。
- 所有比例、税率、阈值和托管期都是教学假设;真实交易要按当期法规和专业意见核对。
阅读导航:上一节:5.3 估值调整项:现金、负债、营运资金 · 下一节:6.2 客户与合同尽调 。
常见错误与边界
- 把尽调当成审计。 审计抽样看报表,尽调追到合同、回款和验收单;只准备报表,买方仍然会要原始凭证。
- 只算收入调整,不算倍数。 12,012 美元的收入缺口按 3.2× 会变成 38,438 美元;谈判时要先确定用哪种口径。
- 把托管当成折价。 托管可能退回,折价不会;应收质量差先用托管处理,别急着在价格上让到底。
- 忽略跨期成本。 4,500 美元的外包跨期在小账上不显眼,按 3.0× 就是 13,500 美元的价格影响。
- 把关联交易当成家务事。 高于市价的 1,200 美元会通过 SDE 放大,且会引发治理追问;提前准备合同、比价和披露。
- 用案例税率做税务结论。 GST 9%、VAT 20%、公司税 25% 都是教学设定;真实申报义务随辖区和年度变化。
- 把阈值当成法律标准。 8% 的重大性、6 个月托管期是教学假设;真实交易由双方谈判和合同约定。
- 只准备一年数据。 跨期、关联交易和税务缺口往往要看三年;只有一年,趋势和习惯都看不出来。
本节的边界是「财务发现如何变成价格与托管」:不展开客户合同、技术 IP 和交割迁移,也不给税务或法律意见。客户与合同的检查见 6.2 客户与合同尽调 ,完整清单见 12.1 尽调清单 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。