《独立开发者的商业指南·资本与退出》6.1 财务尽调

财务尽调把五个核对变成价格调整:收入确认差 5% 对应 12,012 美元,Nord 的应收质量触发全额托管,费用归属与关联交易再按倍数放大。本节用 LedgerLite 与 Nord 算清每项发现的价格影响,并把 escrow 与折价分开。

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

财务尽调不是把审计再做一遍。审计回答报表是否符合准则,尽调回答买方付钱之后还能不能收到同样的钱。买方不会按你的科目表提问,而是按五个核对拆收入、应收、费用、关联交易和税务;每一个核对都要落到一份文件、一笔回款或一条合同条款上。找不到对应证据的部分,最终都会变成价格调整,或者更常见的托管。

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,01212,012卖方接受等额扣减,倍数不动
按 3.2× ARR 重算12,01238,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%,而是要求把整笔应收放进托管,等回款后再释放。

案例应收逾期坏账准备风险合计托管方式
Nord11,265901(8%)225(2%)10%全额 11,265,6 个月
LedgerLite12,000720(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,00025% = 4,5003.0×13,500
关联交易Nord关联外包 6,000高于市价 20% = 1,2003.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 客户与合同尽调 。

常见错误与边界

  1. 把尽调当成审计。 审计抽样看报表,尽调追到合同、回款和验收单;只准备报表,买方仍然会要原始凭证。
  2. 只算收入调整,不算倍数。 12,012 美元的收入缺口按 3.2× 会变成 38,438 美元;谈判时要先确定用哪种口径。
  3. 把托管当成折价。 托管可能退回,折价不会;应收质量差先用托管处理,别急着在价格上让到底。
  4. 忽略跨期成本。 4,500 美元的外包跨期在小账上不显眼,按 3.0× 就是 13,500 美元的价格影响。
  5. 把关联交易当成家务事。 高于市价的 1,200 美元会通过 SDE 放大,且会引发治理追问;提前准备合同、比价和披露。
  6. 用案例税率做税务结论。 GST 9%、VAT 20%、公司税 25% 都是教学设定;真实申报义务随辖区和年度变化。
  7. 把阈值当成法律标准。 8% 的重大性、6 个月托管期是教学假设;真实交易由双方谈判和合同约定。
  8. 只准备一年数据。 跨期、关联交易和税务缺口往往要看三年;只有一年,趋势和习惯都看不出来。

本节的边界是「财务发现如何变成价格与托管」:不展开客户合同、技术 IP 和交割迁移,也不给税务或法律意见。客户与合同的检查见 6.2 客户与合同尽调 ,完整清单见 12.1 尽调清单 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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