以下情境为教学假设,人物与数字不代表经核实的真实案例。
交割清单闭合、钱到账之后,交易还没有结束。对订阅生意,真正的风险在交割后的 30–90 天:客户是否愿意把合同换到买方主体、订阅是否在切换时漏扣、支付通道是否把老客户当成新客户、账号和域名是否真的转到了买方名下。这些事不会出现在估值表里,但会以流失率、退款、坏账和买方扣款的形式出现在交割后的第一个月。
LedgerLite 有 260 家付费客户,ARPA 77 美元,MRR 20,020 美元,平均账期 Net 15。把它作为教学假设,迁移三个月、重叠开票 15 天、迁移期流失率上浮 2 个百分点,可以算出一组具体数字:16 家客户流失、1,232 美元 MRR 损失、10,010 美元双重开票暴露、7,936.60 美元迁移成本。Tidecast 则说明另一种约束:平台账号不是合同,不能按“通知客户”的方式迁移。所有比率、天数与金额都是教学假设。
通知客户:三件事必须同时说清
客户收到迁移通知时,最关心的不是“谁收购了谁”,而是三件事:我的合同跟谁签、我的钱付给谁、我的数据由谁负责。通知里少写任何一件,客户就会在续费时犹豫,或者直接不续。迁移通知不是公关稿,而是一份“变更说明 + 操作指引 + 联系人”的组合。
第一件事是合同主体。客户要知道新主体叫什么、注册地在哪里、和旧主体是什么关系。如果是资产收购,旧主体可能继续存在但不再持有客户合同;如果是股权收购,主体不变但控制权变了。两种情况下客户的合同对手方不同,通知的写法也不同。把“被收购”当成“换了个老板”告诉客户,是最常见的错误。
第二件事是付款信息。支付通道切换时,客户会收到新的收款账户、新的发票抬头、新的扣款描述。这里有一个必须防的风险:骗子会抢在真实通知前发“银行账号变更”邮件。迁移通知要提前约定验证方式,比如应用内公告、已知联系人电话、签名函,而不是只发一封邮件。付款信息变更只走一个渠道,客户才会相信下一个渠道。
第三件事是数据与支持。客户要知数据存在哪里、由谁处理、支持邮箱和工单系统是否变化。对 B2B 订阅,DPA(数据处理协议)的对手方也要换;如果客户是受监管行业,还要提供新主体的安全资料。通知里可以不给全部细节,但要给一个明确的时间表和负责人,否则客户会把“不确定”当成“风险”。
通知时点取决于合同。月付客户通常提前 30 天通知即可;年付客户如果合同里有“主体变更需同意”的条款,可能要提前 60 天,并取得书面同意。LedgerLite 的客户结构是 30% 年付、70% 月付,年付折扣 15%。年付客户数量少但金额大,迁移时要优先处理;月付客户数量多但可以批量通知。把 260 家客户按“年付/月付 × 金额”分成四组,比群发一封邮件有效。
合同主体变更:承继、转让与重新签署
合同从旧主体换到新主体,法律上有三种常见路径:承继、转让、重新签署。三者的客户摩擦、时间和风险不同,不能一概写成“合同自动转移”。
| 路径 | 客户要做什么 | 适用情形 | 大致时间 | 主要风险 |
|---|---|---|---|---|
| 承继 | 确认通知,通常无需重签 | 股权收购,主体不变 | 1–2 周 | 客户误以为对手方已变 |
| 转让 | 签署同意书或确认函 | 资产收购,合同允许转让 | 2–4 周 | 合同禁止转让或要求同意 |
| 重新签署 | 签新合同、新 DPA、新付款授权 | 主体、条款或司法辖区变化 | 4–8 周 | 客户借机比价或流失 |
承继最省事,但只适用于“合同主体没变”的股权收购。资产收购下,客户合同通常要从旧主体转给新主体,这就是转让;如果合同写着“未经书面同意不得转让”,客户不同意,合同就转不过去。重新签署最彻底,也最贵:新条款、新价格、新 DPA、新付款授权,每一项都可能触发客户内部审批。
LedgerLite 的前 5 家客户占收入 31%,最大一家占 9%。这 5 家不能群发通知,要一家一家走:先由创始人电话沟通,再发正式通知,再安排新主体签合同。前 5 家如果丢一家,MRR 直接少 9%;而第 200 家客户流失,只少 0.4%。迁移资源要按金额分配,不是按客户数量平均分配。
Nord Studio 的咨询合同是另一种情况。它的前 3 家客户占收入 48%,合同多为 Net 30,且常有“未经同意不得转让”的条款。咨询合同承继的不是“订阅关系”,而是“人”和“交付责任”;客户签新合同前会问“还是原来两个人做吗”。这类合同迁移的摩擦不在系统,而在信任,时间通常比订阅更长。
订阅与支付通道切换
订阅迁移的核心是“不要断扣、不要重扣、不要错扣”。断扣是客户该续费时没扣到,重扣是同一周期扣了两次,错扣是扣款主体或金额不对。三种错误都会导致退款、工单和流失,而它们的成因通常是同一个:旧系统和新系统在切换窗口里同时运行。
LedgerLite 的教学假设是 15 天重叠开票。260 家客户里,约 130 家在重叠窗口内会收到两张发票:旧主体一张、新主体一张。按 MRR 20,020 美元、15 天计算,双重开票暴露是 10,010 美元。这不是损失,而是“在途金额”:客户可能付给旧主体、付给新主体、或者两边都付后再要求退款。暴露越大,对账工单越多。
| 迁移成本项 | 计算 | 金额 |
|---|---|---|
| 流失收入(3 个月) | 16 家 × 77 × 3 | 3,696.00 |
| 支付失败 | 20,020 × 3% | 600.60 |
| 重复发票管理 | 130 张 × 4 | 520.00 |
| 账号转移 | 260 家 × 12 | 3,120.00 |
| 合计 | 7,936.60 |
支付失败按 3% 的教学假设算,是 600.60 美元。真实切换中,失败率取决于卡组织、发卡行、扣款描述和 3DS 策略;新主体的商户号没有历史记录,首月失败率通常高于稳态。失败不是“少一笔收入”,而是“多一轮催收”:重试、邮件、电话、暂停服务,都会占用支持时间。迁移预算里要留出这笔人工。
Net 15 的账期让现金更紧。月收入 20,020 美元、账期 15 天,意味着任意时点约有 10,010 美元应收在途。切换支付通道时,旧通道的最后一笔结算可能延迟 7–30 天,新通道的首笔结算也要等一个周期;两端叠加,卖方可动用的现金会比账面 MRR 少半个月以上。交割后的现金流预测不能只用 MRR,要把“在途应收 + 新通道结算延迟”一起算。
流失是迁移最大的隐性成本。LedgerLite 的稳态月流失是 1.5%,教学假设迁移期上浮 2 个百分点,达到 3.5%。三个月多流失 16 家客户,损失 MRR 1,232 美元;按三个月计是 3,696 美元收入,按年化计是 14,784 美元 ARR。16 家客户不是一次性消失,而是在通知、换签、换卡、首扣几个节点分批走;越晚接触的客户,越容易在“不知道怎么付”时放弃。
账号与域名转移
账号和域名是迁移里最“物理”的部分。域名要改注册人、改 DNS、改邮箱;账号要改所有权、改管理员、改恢复邮箱;支付通道要改商户主体、改结算账户、改 API 密钥。这些动作不像合同那样可以“承继”,每一项都要在系统里真实发生。
域名转移通常要 5–7 天,取决于注册商和原注册人确认。转移前要解锁域名、拿到授权码、确认管理员邮箱可用;转移后要检查 DNS 记录、DNSSEC、邮件记录和证书。DNS 的 TTL 如果在切换前设得很长,改动生效会慢;如果设得很短,切换窗口的解析压力会大。迁移计划里要写“提前 48 小时把 TTL 降到 300 秒”,而不是交割当天才改。
账号转移的难点是“所有权”和“控制权”不是一回事。把账号管理员改成买方邮箱,不等于买方拥有账号;平台可能要求原持有人提交转让申请,或者直接规定账号不可转让。Tidecast 就属于后者:Steam 账号不能按资产转移,买方要重新签发行协议,愿望单、评测和好友关系不会跟着走。
Tidecast 的 80% 销量来自 Steam。平台账号不可转让,意味着交易里能交的是代码、美术、音乐、商标和发行权,不是“店铺本身”。买方接手后要重新上架、重新积累评测,首年销量可能低于 9,600 份的基线。
对这类资产,交割清单要把“账号转移”改成“新协议签署 + 重新上架”,并把愿望单和评测的损失写进价格调整。用“账号过户”做假设,会在交割后才发现平台根本不允许。
写成代码:customer-migration.ts
下面的 customer-migration.ts 把迁移拆成三层数字。第一层是流失:lostCustomers 只按“迁移期上浮的流失率”算增量,不重复计算稳态流失;lostRevenueCents 是迁移窗口内的收入损失,lostArrCents 是年化后的 ARR 损失。第二层是暴露:doubleBillingExposureCents 是在途发票金额,netDaysCashTiedCents 是 Net 账期占用的现金,两者都不是当期成本。第三层是一次性成本:支付失败、重复发票管理、账号转移。
// file: src/capital/customer-migration.ts
// tier: L1
// verify: node --test test/capital/customer-migration.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 MigrationInput = {
customers: number;
arpaCents: Cents;
baseMonthlyChurnBps: Bps;
churnUpliftBps: Bps;
migrationMonths: number;
overlapDays: number;
netDays: number;
paymentFailureBps: Bps;
invoiceAdminCents: Cents;
accountTransferCents: Cents;
};
export type MigrationOutcome = {
baselineMrrCents: Cents;
churnDuringMigrationBps: Bps;
lostCustomers: number;
lostMrrCents: Cents;
lostRevenueCents: Cents;
lostArrCents: Cents;
lostRevenueShareBps: Bps;
duplicateInvoiceCount: number;
doubleBillingExposureCents: Cents;
netDaysCashTiedCents: Cents;
paymentFailureCents: Cents;
invoiceAdminCostCents: Cents;
accountTransferCostCents: Cents;
oneTimeCostCents: Cents;
totalCostCents: Cents;
totalExposureCents: Cents;
};
function checkAmount(value: Cents, label: string): string | null {
if (!Number.isInteger(value) || value < 0) {
return `${label} must be a non-negative integer amount in cents`;
}
return null;
}
function checkBps(value: Bps, label: string): string | null {
if (!Number.isInteger(value) || value < 0 || value > 10000) {
return `${label} must be between 0 and 10000 bps`;
}
return null;
}
/**
* A migration creates three different kinds of number, and they should not be
* added together. Lost revenue is recurring: churn rises while customers are
* asked to re-sign. Double billing and the Net 15 balance are exposure, not
* cost: the money is in flight, not gone. Payment failures, duplicate invoice
* admin and account transfers are one-time costs. Keeping them apart prevents
* a migration budget from double-counting the same month of revenue.
*/
export function customerMigration(
input: MigrationInput,
): Result<MigrationOutcome> {
if (!Number.isInteger(input.customers) || input.customers <= 0) {
return fail("customers must be a positive integer");
}
if (!Number.isInteger(input.arpaCents) || input.arpaCents <= 0) {
return fail("ARPA must be a positive integer amount in cents");
}
for (const [value, label] of [
[input.baseMonthlyChurnBps, "base monthly churn"],
[input.churnUpliftBps, "churn uplift"],
[input.paymentFailureBps, "payment failure"],
] as Array<[Bps, string]>) {
const error = checkBps(value, label);
if (error) {
return fail(error);
}
}
if (input.baseMonthlyChurnBps + input.churnUpliftBps > 10000) {
return fail("combined churn cannot exceed 10000 bps");
}
if (
!Number.isInteger(input.migrationMonths) ||
input.migrationMonths < 1 ||
input.migrationMonths > 12
) {
return fail("migration months must be an integer between 1 and 12");
}
for (const [value, label] of [
[input.overlapDays, "overlap days"],
[input.netDays, "net days"],
] as Array<[number, string]>) {
if (!Number.isInteger(value) || value < 0 || value > 365) {
return fail(`${label} must be an integer between 0 and 365`);
}
}
for (const [value, label] of [
[input.invoiceAdminCents, "invoice admin"],
[input.accountTransferCents, "account transfer"],
] as Array<[Cents, string]>) {
const error = checkAmount(value, label);
if (error) {
return fail(error);
}
}
const baselineMrrCents = input.customers * input.arpaCents;
const churnDuringMigrationBps =
input.baseMonthlyChurnBps + input.churnUpliftBps;
const lostCustomers = Math.round(
(input.customers * input.churnUpliftBps * input.migrationMonths) / 10000,
);
const lostMrrCents = lostCustomers * input.arpaCents;
const lostRevenueCents = lostMrrCents * input.migrationMonths;
const duplicateInvoiceCount = Math.round(
(input.customers * input.overlapDays) / 30,
);
const doubleBillingExposureCents = Math.round(
(baselineMrrCents * input.overlapDays) / 30,
);
const netDaysCashTiedCents = Math.round(
(baselineMrrCents * input.netDays) / 30,
);
const paymentFailureCents = applyRate(
baselineMrrCents,
input.paymentFailureBps,
);
const invoiceAdminCostCents =
duplicateInvoiceCount * input.invoiceAdminCents;
const accountTransferCostCents =
input.customers * input.accountTransferCents;
const oneTimeCostCents =
paymentFailureCents + invoiceAdminCostCents + accountTransferCostCents;
return ok({
baselineMrrCents,
churnDuringMigrationBps,
lostCustomers,
lostMrrCents,
lostRevenueCents,
lostArrCents: lostMrrCents * 12,
lostRevenueShareBps: divide(
lostRevenueCents,
baselineMrrCents * input.migrationMonths,
),
duplicateInvoiceCount,
doubleBillingExposureCents,
netDaysCashTiedCents,
paymentFailureCents,
invoiceAdminCostCents,
accountTransferCostCents,
oneTimeCostCents,
totalCostCents: lostRevenueCents + oneTimeCostCents,
totalExposureCents: doubleBillingExposureCents + netDaysCashTiedCents,
});
}
代码里有三个刻意的选择。第一,流失只算 churnUpliftBps,因为稳态流失在估值和买方模型里已经计入;把 1.5% 和 2.0% 都算进迁移成本,会重复计算。第二,双重开票与 Net 15 放在 totalExposureCents,不放进 totalCostCents;暴露是“钱在途”,不是“钱没了”。第三,lostRevenueShareBps 用迁移窗口的收入做分母,回答“这三个月的收入里有多少被迁移吃掉”,而不是用年收入做分母。
把 LedgerLite 代入:260 家客户、ARPA 7,700 美分、稳态流失 150 基点、迁移上浮 200 基点、3 个月、重叠 15 天、Net 15、支付失败 300 基点、每张重复发票 400 美分、每户账号转移 1,200 美分。返回基线 MRR 2,002,000 美分、迁移期流失率 350 基点、流失 16 家、损失 MRR 123,200 美分、三个月收入损失 369,600 美分、年化 1,478,400 美分;重复发票 130 张、双重开票暴露 1,001,000 美分、Net 15 占用 1,001,000 美分;一次性成本 424,060 美分、总成本 793,660 美分、总暴露 2,002,000 美分。
小结
- 迁移通知要同时说清三件事:合同跟谁签、钱付给谁、数据由谁负责;少一件,客户就会在续费时犹豫。
- 合同换主体有承继、转让、重新签署三条路径;资产收购通常要转让或重签,股权收购才可能承继。
- LedgerLite 的前 5 家客户占收入 31%,最大一家 9%;迁移资源要按金额分配,不是按 260 家平均分配。
- 15 天重叠开票产生约 130 张重复发票、10,010 美元双重开票暴露;这不是损失,而是在途金额与对账成本。
- Net 15 让任意时点约有 10,010 美元应收在途;叠加新旧支付通道的结算延迟,交割后现金比 MRR 少半个月以上。
- 迁移期流失率上浮 2 个百分点、持续 3 个月,多流失 16 家客户,损失 MRR 1,232 美元、三个月收入 3,696 美元、年化 ARR 14,784 美元。
- 一次性迁移成本 7,936.60 美元:流失收入 3,696、支付失败 600.60、重复发票管理 520、账号转移 3,120。
- Tidecast 的 Steam 账号不可转让,买方要重新签发行协议;愿望单、评测和好友关系不会跟着走,交割清单要写成“新协议 + 重新上架”。
阅读导航:上一节:8.1 交割清单与资金流 · 下一节:8.3 代码、域名与数据交割 。
常见错误与边界
- 把迁移通知当公关稿。 客户要的是合同对手方、付款信息、数据责任三件事;只写“我们被收购了”,客户无法完成内部审批。
- 付款信息只发邮件。 骗子会抢发“银行账号变更”;付款变更要走应用内公告、已知联系人电话或签名函,不能只靠一封邮件。
- 假设合同自动转移。 资产收购下客户合同通常要转让或重签;合同写着“需书面同意”时,客户不同意就转不过去。
- 按客户数量平均分配迁移资源。 前 5 家占收入 31%,最大一家 9%;丢第 200 家的影响远小于丢最大一家。
- 把双重开票暴露当损失。 10,010 美元是在途金额,不是当期成本;真正的成本是对账工单、退款和客户体验。
- 忽略 Net 15 的现金占用。 月收入 20,020 美元、账期 15 天,任意时点约 10,010 美元应收在途;新旧通道结算延迟会进一步拉大现金缺口。
- 只算稳态流失。 迁移期流失率上浮 2 个百分点、持续 3 个月,多流失 16 家;稳态 1.5% 已经在估值里,不能重复计入迁移成本。
- 把支付失败当小数。 3% 的失败率是 600.60 美元收入,但背后是重试、催收、暂停服务和工单,人工成本另计。
- 假设账号可以过户。 Steam 等平台账号不可转让,买方要重新签发行协议;把“账号转移”写进交割清单,会在交割后才发现做不到。
- 迁移结束才检查域名和 DNS。 域名转移要 5–7 天,DNS 生效取决于 TTL;交割当天才改,停机时间会落在买方身上并触发扣款。
本节的边界是“订阅制 B2B、固定 ARPA、线性流失上浮”的静态模型。它假设客户按金额分层、支付失败率不随客户规模变化、双重开票按 30 天折算,不含年付客户的退款、跨境 VAT/GST 重开票、支付通道保留金、平台账号的重新审核周期和客户主动比价导致的降价。它足够回答“迁移要花多少钱、流失多少客户”,但不足以回答“某个大客户会不会借机解约”。后者要把合同条款、客户集中度和替代方案放在一起看,见 6.2 客户与合同尽调 ;代码与数据交割见 8.3 代码、域名与数据交割 。本节不构成法律意见,合同转让与客户通知的具体文本请由律师审阅。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。