《独立开发者的商业指南·资本与退出》11.2 用户、数据与订阅的收尾

关停公告发出去,260 家客户不会同时消失:按 8% 月流失,90 天后剩 202 家,流失 58 家、MRR 少 4,466 美元;78 家年付客户的未使用期退款本应 30,030 美元,但账面递延只有 22,000 美元,缺口 8,030 美元。本节给出退款、导出、删除与 90 天排期的完整口径。

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

关停决策做完,下一步不是「把网站关掉」,而是把三件事同时收尾:用户还能不能用、数据怎么导出和删除、订阅的钱怎么退。三件事各有自己的时间表,也各有自己的成本。用户收尾做得好,流失是渐进的、可预测的;做得差,公告当天就会变成退款潮、工单潮和公开投诉。

这一节用 LedgerLite 的教学假设算三组数:公告后的流失、按未使用期比例计算的退款义务、90 天收尾的总成本。LedgerLite 有 260 家付费客户、ARPA 77 美元、递延收入 22,000 美元、稳态月流失 1.5%。公告后教学假设月流失升到 8%,收尾期 3 个月,数据导出窗口 60 天。所有比率、窗口和金额都是教学假设。

收尾不是关站,是三条并行的时间线

关停公告同时启动三条时间线。第一条是服务线:什么时候停止新签、什么时候停止续费、什么时候只读、什么时候完全关闭。第二条是数据线:客户什么时候能导出、导出窗口多长、窗口结束后多久删除、删除后如何证明。第三条是资金线:哪些客户有退款权、退多少、什么时候退、退款和最后一笔扣款怎么对账。

时间线关键动作对客户的影响对现金的影响
服务停新签 → 停续费 → 只读 → 关闭能不能继续用、用到哪天收入逐月下降
数据通知 → 导出窗口 → 删除 → 证明数据能不能带走、带多久导出与删除成本
资金停扣 → 计算退款 → 退款 → 对账已付的钱能不能退退款支出与支付通道费用

三条线不能各自为政。服务线关得太早,客户来不及导出数据,会要求退款;数据线窗口太短,客户会把「来不及导出」当成「数据被扣留」,投诉和退款都会增加;资金线退得太晚,客户会在公开渠道说「公司跑路」,把本来可控的收尾变成声誉事件。收尾排期的核心不是「哪天关站」,而是「三条线在哪一天对齐」。

对齐的原则是:先给数据,再停服务,最后退钱。客户先拿到数据,才会相信退款会到账;服务先停、数据后给,客户会认为公司在用数据当筹码。LedgerLite 的 90 天排期就是按这个顺序排的:第 0–14 天通知并开放导出,第 15–45 天退款和迁移支持,第 46–75 天只读,第 76–90 天关闭并开始删除。

LedgerLite:公告后的流失与退款

先算流失。LedgerLite 的稳态月流失是 1.5%,年化约 16.6%。关停公告不是正常流失事件:客户知道服务要结束,会提前离开、停止使用、寻找替代品。教学假设公告后的月流失升到 8%,是稳态的 5.3 倍。按 3 个月收尾期逐月复利:

第 1 个月:260 × (1 − 8%) = 239
第 2 个月:239 × (1 − 8%) = 220
第 3 个月:220 × (1 − 8%) = 202
流失客户 = 260 − 202 = 58

58 家客户流失,按 ARPA 77 美元计算,损失 MRR 4,466 美元,年化 ARR 53,592 美元。这个数字要和「正常情况下的 3 个月流失」比较:按 1.5% 稳态,3 个月后剩 248 家,只流失 12 家。公告把流失从 12 家推到 58 家,多流失 46 家,多损失 MRR 3,542 美元。收尾预算要按「增量流失」算,不是按「全部流失」算,因为稳态流失在任何情况下都会发生。

再看退款。LedgerLite 的客户结构是 30% 年付、70% 月付。年付客户 78 家,平均还剩 5 个月未使用。按未使用期比例,每家的退款义务是 77 × 5 = 385 美元,78 家合计 30,030 美元。

潜在退款 = 78 × 77 × 5 = 30,030
账面递延收入 = 22,000
实际退款上限 = min(30,030, 22,000) = 22,000
退款缺口 = 30,030 − 22,000 = 8,030

这里有一个容易被忽略的差:潜在退款 30,030 美元,账面递延收入只有 22,000 美元,差 8,030 美元。原因不是算错,而是两个口径不同。递延收入是「已经收到、但还没确认」的收入,按实际收款净额记录;年付客户享受 15% 折扣,所以账面金额低于按标价计算的未使用期价值。退款如果按标价和未使用月数计算,就会超过账面负债。超出的 8,030 美元要由当期现金承担,不能只靠「冲减递延收入」解决。

退款口径金额说明
按标价 × 未使用月数30,03078 家 × 77 × 5
账面递延收入22,000已收未确认,净额口径
实际退款上限22,000不能超过账面负债
现金缺口8,030超出递延的部分由当期现金承担

月付客户没有预付款,理论上没有「未使用期」退款;但公告后的最后一笔扣款仍可能引发争议。教学假设月付客户的退款请求率是 3%,按 182 家、ARPA 77 美元、平均退半个月计算,退款约 182 × 77 × 3% × 0.5 = 210 美元。这个数字很小,但工单成本不低:每一笔退款都要核对扣款日、使用记录和退款政策,支持时间要单独预算。

90 天收尾排期

把三条线排成 90 天,关键是每个阶段只做一件事,且每件事都有明确的截止日。下面这张表是 LedgerLite 的教学排期,日期以公告日为第 0 天。

阶段天数服务线数据线资金线
通知0–14停止新签,停止自动续费开放导出,发布 FAQ停止新扣款,冻结退款队列
退款与迁移15–45正常服务,提供迁移指引导出窗口开放,人工协助大客户年付退款到账,月付争议处理
只读46–75关闭写入,保留登录与导出导出窗口关闭,发送删除预告处理迟到退款与支付争议
关闭76–90停止登录,关闭服务开始删除,保留删除日志完成对账,关闭支付账户
删除证明91–150无删除完成,出具证明保留财务与税务记录

第 0–14 天的核心是「一次说清」。通知里要写明服务截止日、数据导出截止日、退款资格和退款时间,以及一个能回复的邮箱。通知渠道不能只靠邮件:B2B 客户可能用共享邮箱,邮件会被当成营销;应用内公告、后台横幅和已知联系人电话要同时用。LedgerLite 的 260 家客户里,前 5 家占收入 31%,这 5 家要单独打电话,不能只发群发邮件。

第 15–45 天是退款和迁移的高峰。年付客户的退款按未使用月数计算,退款方式尽量原路退回;原路退回失败时,再走人工转账。迁移支持不是「帮客户搬家」,而是「给客户一份可用的导出文件 + 一份替代方案清单」。LedgerLite 的导出请求率教学假设是 40%,即 104 家;每家的导出支持成本 15 美元,合计 1,560 美元。这个成本包括生成导出文件、校验字段、解释格式和一次人工回复。

第 46–75 天进入只读。只读的意思是客户还能登录、还能导出,但不能新增或修改数据。只读期的作用是给迟到客户一个缓冲,也给公司一个对账窗口。这个阶段要继续发删除预告:导出窗口关闭后,数据不会立刻删除,而是进入一个短暂的保留期;保留期的长度要在通知里写清楚,不能临时改。

第 76–90 天关闭服务并开始删除。关闭的顺序是先停登录、再停后台任务、最后停数据库写入;删除的顺序是先删临时文件和日志、再删业务数据、最后删备份。删除不是「点一下按钮」,要有删除日志、校验记录和一份给客户的删除证明。LedgerLite 的删除成本教学假设是 3,000 美元,包括脚本、校验和人工复核。

90 天收尾的总成本是:

成本项计算金额
年付退款min(78 × 77 × 5, 22,000)22,000
通知260 家 × 3780
数据导出104 家 × 151,560
数据删除教学假设3,000
支持工时60 小时 × 402,400
合计29,740

29,740 美元是「收尾的直接成本」,不包括增量流失的 MRR 4,466 美元和年化 ARR 53,592 美元。如果把流失也算进去,收尾的总代价接近 8 万美元;但流失是收入损失,不是现金支出,两者要分开看。关停决策用的是现金流口径,收尾预算用的是现金支出加收入损失两个口径。

数据导出、删除与合规

数据收尾的目标不是「删得干净」,而是「能证明删得干净」。对 B2B 客户,数据可能包含财务记录、客户名单和业务凭证;对受监管行业,删除还要满足保留义务。收尾方案要把「删除」拆成三步:导出、保留期、删除与证明。

导出要解决三个问题:给什么格式、给多长时间、给到什么程度。格式优先选客户能直接打开的通用格式,比如 CSV、JSON 和 PDF;专有格式会让客户在失去服务后无法读取。导出窗口要足够长,让客户完成内部审批和迁移;60 天是教学假设,不是法定要求。导出程度要写清楚:是「全部数据」还是「客户可访问的数据」,是「原始记录」还是「汇总报表」。含糊的承诺会在收尾期变成工单。

保留期要解决「什么时候必须删」和「什么时候可以留」。有些数据必须保留一段时间,比如财务凭证、税务记录、合同和争议材料;有些数据必须在窗口结束后删除,比如业务数据、日志和个人信息。保留期不是「越短越好」,而是「按数据类型分别设定」:财务记录按当地法规保留,业务数据在导出窗口结束后删除,日志在收尾结束后删除。LedgerLite 的教学假设是导出窗口 60 天、保留期 30 天、删除在 90 天内完成。

删除要留下证明。证明不需要公开客户数据,只需要记录:删除了哪些数据集、什么时间删除、用什么方法删除、谁复核、备份是否一并删除。对客户,可以提供一份「删除完成确认」,写明删除范围和时间;对监管或买方,可以保留内部删除日志。没有证明的删除,在客户看来和「没删」没有区别。

合规的边界要说清楚:这一节不给法律意见,也不引用具体法条。数据处理协议(DPA)、跨境传输、保留义务和删除时限,都要按合同和适用法规逐项核对。LedgerLite 有企业订阅协议和 DPA;收尾时要检查 DPA 里的「终止后数据处理」条款,确认通知期限、导出格式、删除时限和子处理商的处理方式。条款没写清楚的,要在公告前补一份数据处理附录,而不是等客户来问。

Tidecast 是另一种收尾。它是一次买断制游戏,没有订阅,也就没有「未使用期退款」;但平台下架会触发另一组问题:已购买玩家的下载权、更新承诺、平台结算和评测。买断制的收尾重点不是退款比例,而是「已售出的权益怎么继续兑现」。

写成代码:user-data-winddown.ts

下面的 user-data-winddown.ts 把收尾拆成两组数字。第一组是流失:remainingCustomers 按公告后的月流失逐月复利,churnedCustomers 是增量流失,lostMrrCents 是流失客户的月度收入。第二组是退款:potentialRefundCents 按「年付客户 × ARPA × 未使用月数」算,refundCents 以账面递延收入为上限,refundCapGapCents 是超出递延的现金缺口。最后把通知、导出、删除和支持工时加进 totalWinddownCostCents。

// file: src/capital/user-data-winddown.ts
// tier: L1
// verify: node --test test/capital/user-data-winddown.test.ts

import { type Cents } from "../core/money.ts";
import { type Bps, divide } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";

export type WinddownInput = {
  customers: number;
  arpaCents: Cents;
  annualShareBps: Bps;
  prepaidMonths: number;
  unusedMonths: number;
  deferredRevenueCents: Cents;
  baselineMonthlyChurnBps: Bps;
  announcementChurnBps: Bps;
  winddownMonths: number;
  exportRequestBps: Bps;
  exportCostCents: Cents;
  deletionCostCents: Cents;
  supportHours: number;
  supportRateCents: Cents;
  notificationCents: Cents;
};

export type WinddownOutcome = {
  annualCustomers: number;
  potentialRefundCents: Cents;
  refundCents: Cents;
  refundCapGapCents: Cents;
  remainingCustomers: number;
  churnedCustomers: number;
  churnMultipleBps: Bps;
  lostMrrCents: Cents;
  lostArrCents: Cents;
  exportRequests: number;
  exportCostCents: Cents;
  supportCostCents: Cents;
  totalWinddownCostCents: Cents;
  refundShareOfDeferredBps: Bps;
};

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 shutdown announcement changes two numbers at once. Churn rises above the
 * steady-state rate, so the customer base shrinks faster than the business
 * plan assumed. Prepaid annual customers also gain a refund claim measured by
 * unused months; that claim can exceed the deferred-revenue balance because
 * the balance was recorded net of the annual discount. The refund is capped at
 * the recorded liability, and the gap is the cash the owner must find.
 */
export function userDataWinddown(
  input: WinddownInput,
): Result<WinddownOutcome> {
  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.annualShareBps, "annual share"],
    [input.baselineMonthlyChurnBps, "baseline monthly churn"],
    [input.announcementChurnBps, "announcement churn"],
    [input.exportRequestBps, "export request share"],
  ] as Array<[Bps, string]>) {
    const error = checkBps(value, label);
    if (error) {
      return fail(error);
    }
  }
  for (const [value, label] of [
    [input.prepaidMonths, "prepaid months"],
    [input.unusedMonths, "unused months"],
  ] as Array<[number, string]>) {
    if (!Number.isInteger(value) || value < 0 || value > 36) {
      return fail(`${label} must be an integer between 0 and 36`);
    }
  }
  if (input.unusedMonths > input.prepaidMonths) {
    return fail("unused months cannot exceed prepaid months");
  }
  if (
    !Number.isInteger(input.winddownMonths) ||
    input.winddownMonths < 1 ||
    input.winddownMonths > 12
  ) {
    return fail("wind-down months must be an integer between 1 and 12");
  }
  if (!Number.isInteger(input.supportHours) || input.supportHours < 0) {
    return fail("support hours must be a non-negative integer");
  }
  for (const [value, label] of [
    [input.deferredRevenueCents, "deferred revenue"],
    [input.exportCostCents, "export cost"],
    [input.deletionCostCents, "deletion cost"],
    [input.supportRateCents, "support rate"],
    [input.notificationCents, "notification"],
  ] as Array<[Cents, string]>) {
    const error = checkAmount(value, label);
    if (error) {
      return fail(error);
    }
  }

  const annualCustomers = Math.round(
    (input.customers * input.annualShareBps) / 10000,
  );
  const potentialRefundCents =
    annualCustomers * input.arpaCents * input.unusedMonths;
  const refundCents = Math.min(
    potentialRefundCents,
    input.deferredRevenueCents,
  );
  const refundCapGapCents = potentialRefundCents - refundCents;

  let remainingCustomers = input.customers;
  for (let month = 0; month < input.winddownMonths; month += 1) {
    remainingCustomers = Math.round(
      (remainingCustomers * (10000 - input.announcementChurnBps)) / 10000,
    );
  }
  const churnedCustomers = input.customers - remainingCustomers;
  const lostMrrCents = churnedCustomers * input.arpaCents;

  const exportRequests = Math.round(
    (input.customers * input.exportRequestBps) / 10000,
  );
  const exportCostCents = exportRequests * input.exportCostCents;
  const supportCostCents = input.supportHours * input.supportRateCents;

  return ok({
    annualCustomers,
    potentialRefundCents,
    refundCents,
    refundCapGapCents,
    remainingCustomers,
    churnedCustomers,
    churnMultipleBps: divide(
      input.announcementChurnBps,
      input.baselineMonthlyChurnBps,
    ),
    lostMrrCents,
    lostArrCents: lostMrrCents * 12,
    exportRequests,
    exportCostCents,
    supportCostCents,
    totalWinddownCostCents:
      refundCents +
      input.notificationCents +
      exportCostCents +
      input.deletionCostCents +
      supportCostCents,
    refundShareOfDeferredBps: divide(
      refundCents,
      input.deferredRevenueCents,
    ),
  });
}

把 LedgerLite 的数字换成美分代进去:年付客户 78 家,潜在退款 3,003,000 美分(30,030 美元);账面递延 2,200,000 美分,实际退款 2,200,000 美分(22,000 美元),缺口 803,000 美分(8,030 美元);3 个月后剩 202 家,流失 58 家,损失 MRR 446,600 美分(4,466 美元)、ARR 5,359,200 美分(53,592 美元);导出请求 104 家、成本 156,000 美分(1,560 美元),支持成本 240,000 美分(2,400 美元),总收尾成本 2,974,000 美分(29,740 美元)。

代码里有三个刻意的选择。第一,退款以递延收入为上限,而不是以潜在退款为上限;超出部分不是「不用退」,而是「要用当期现金退」,所以单独输出 refundCapGapCents。第二,流失按公告后的月流失逐月复利,而不是用「客户数 × 月流失 × 月数」一次算完;复利更接近实际,因为每月离开的客户不再参与下个月的流失。第三,总成本包含退款,但流失只作为收入损失单独输出;把两者相加会重复计算同一笔钱。

小结

  • 关停收尾有服务、数据、资金三条时间线;顺序是先给数据、再停服务、最后退钱。
  • LedgerLite 公告后月流失从 1.5% 升到 8%,是稳态的 5.3 倍;3 个月后剩 202 家,流失 58 家,损失 MRR 4,466 美元、ARR 53,592 美元。
  • 正常 3 个月只流失 12 家;公告把增量流失推到 46 家,多损失 MRR 3,542 美元;收尾预算要按增量算。
  • 78 家年付客户、平均 5 个月未使用,按标价计算的退款义务是 30,030 美元;账面递延只有 22,000 美元,现金缺口 8,030 美元。
  • 退款上限是账面递延收入,不是潜在退款;超出部分由当期现金承担,不能只冲减递延。
  • 90 天排期:0–14 天通知并开放导出,15–45 天退款和迁移,46–75 天只读,76–90 天关闭并开始删除,91–150 天完成删除证明。
  • 收尾直接成本 29,740 美元:退款 22,000、通知 780、导出 1,560、删除 3,000、支持 2,400。
  • 收尾成本要和流失损失分开看:前者是现金支出,后者是收入损失;两者口径不同,不能直接相加。
  • 数据收尾要能证明删除:导出格式、窗口长度、保留期、删除日志和客户确认,缺一项都会变成工单。
  • Tidecast 没有订阅退款,但平台下架会触发已购玩家的下载权、更新承诺和结算问题;买断制的收尾重点是权益兑现,不是退款比例。

阅读导航:上一节:11.1 关停决策:继续、出售、关停 · 下一节:11.3 债务、合同与主体注销 。

常见错误与边界

  1. 公告当天就关服务。 客户来不及导出数据,会把「来不及」当成「被扣留」,退款和投诉都会增加;先给数据,再停服务。
  2. 只发一封邮件。 B2B 客户的共享邮箱会把通知当营销;应用内公告、后台横幅和已知联系人电话要同时用。
  3. 按稳态流失做收尾预算。 公告后的流失是稳态的 5 倍以上;按 1.5% 算会低估 46 家增量流失和 3,542 美元 MRR。
  4. 按标价算退款,不看递延。 潜在退款 30,030 美元,账面递延只有 22,000 美元;差额 8,030 美元要由当期现金承担。
  5. 以为退款可以全部冲减递延。 递延是负债,退款是现金支出;超过递延的部分直接吃现金,不影响利润表的收入确认。
  6. 导出窗口太短。 60 天是教学假设,不是法定要求;窗口要够客户完成内部审批和迁移,否则会变成退款理由。
  7. 删除没有证明。 客户看不到删除日志,就会认为数据还在;删除范围、时间、方法和复核人要留记录。
  8. 把财务记录一起删。 税务、合同和争议材料有保留义务;删除要按数据类型分开,不是「一键清空」。
  9. 收尾成本和流失损失相加。 29,740 美元是现金支出,53,592 美元是 ARR 损失;两者口径不同,相加会重复计算。
  10. 忽略支付通道的结算周期。 关闭支付账户前要确认最后一笔结算和退款都完成;账户先关,退款会失败。

本节的边界是「订阅制 B2B、固定 ARPA、线性流失上浮」的收尾模型。它覆盖流失、退款、导出、删除和 90 天排期,不展开具体的 DPA 条款、跨境传输、税务保留年限和平台下架规则。所有流失率、退款比例、窗口长度和成本都是教学假设;具体的数据处理与退款义务以合同和适用法规为准,本节不构成法律意见。退款与订阅状态见 1.2 订阅生命周期 ;数据删除与保留见 7.3 保留与删除任务 ;关停的债务与主体注销见 11.3 债务、合同与主体注销 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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