所有文章
Deep dive阅读需 8 分钟

StoreKit 2 退款检测归结于交易上的一个属性,也就是 revocationDate

当 Apple 为你的某位客户退款时,在你的服务器任务运行之前,这笔退款其实已经通过交易的 revocationDate 出现在你的 app 内部了。本文说明 StoreKit 2 退款检测在设备上出现的位置,revocationDate 和 revocationReason 告诉你什么,以及为什么客户端负责速度、服务器负责真相。

一部智能手机在昏暗的开发者桌面上发光,旁边放着一只机械时钟,展示 StoreKit 2 退款检测在 app 内部浮现

要点

  • 在 StoreKit 2 中,已退款的购买会在其 Transaction 上带有非 nil 的 revocationDate,因此你的 app 无需调用服务器即可自行检测到退款。
  • revocationDate 会在 App Store 为某笔交易退款、或客户通过 Family Sharing 失去该购买时被设置,所以非 nil 的日期并不总是意味着退款。
  • revocationReason 告诉你原因:developerIssue 表示客户提到了你 app 中的问题,other 则涵盖其余所有退款原因。
  • Transaction.currentEntitlements 已经排除了已退款和已撤销的购买,所以最干净的客户端访问判断,就是看某个产品是否仍出现在其中。
  • 只有当你在启动时就开始监听,Transaction.updates 才会送达在 app 关闭期间发生的退款,因此缺少一个 Task 就意味着漏掉一笔退款。
  • 客户端检测只在 app 打开时触发,这正是 App Store Server Notifications V2 的 REFUND 仍然是权威信号的原因,它能阻止你继续为已退款用户付费提供服务。
  • 退款可以被撤销,一旦撤销,交易上的撤销字段会被移除,你需要恢复此前切断的访问权限。

大多数 app 是从自己的服务器、通过一条 App Store Server Notification 才得知 Apple 退款的,却从未注意到同一笔退款其实早已存在于 app 内部。它就在交易上,位于一个名为 revocationDate 的属性里,读取它可以让你的 app 在已退款客户下次打开时就切断其访问权限,而不必等待后端任务。StoreKit 2 退款检测是一个大多数团队会忽略的客户端信号。下面就说明退款究竟在设备上的哪里浮现,它告诉你什么、不告诉你什么,以及为什么它应当与你的服务器通知并存,而不是取而代之。

StoreKit 2 中退款出现的位置

StoreKit 2 以签名值的形式把交易交给你,退款并不会删除交易,而是给它做上标记。Transaction 上有两个属性承载这个标记,在一笔健康购买的整个生命周期里,它们都保持 nil。一旦其中之一变为非 nil,就说明 App Store 已经收回了这笔购买。

revocationDate 是会翻转的那个字段

revocationDate 是一个可选的 Date。Apple 自己的描述很精确:它是 App Store 为该交易退款、或将其从 Family Sharing 中撤销的日期。对于仍然有效的购买,它是 nil。退款一旦被处理,它就保存那次退款的时间戳。这一项检查,revocationDate 是否非 nil,就是客户端退款检测的全部。其余的一切,都是在它之上叠加的细节。

revocationReason 告诉你 Apple 为何收回它

revocationReason 就在日期旁边,解释其原因。StoreKit 为退款给出两个有意义的值。developerIssue 表示客户告诉 Apple,退款是由于你 app 中实际存在或被认为存在的问题。other 涵盖其余所有原因。第三个值 upgradedToBundle 根本不是退款;它标记的是 App Store 因客户转到某个订阅捆绑包而撤销的交易。在采取行动前先读取原因,因为 developerIssue 才是值得统计的那个:一批这样的原因,就是你自己的产品在告诉你它在哪里出了问题。

属性类型非 nil 值意味着什么
revocationDateDate?App Store 在这一天为此交易退款,或通过 Family Sharing 撤销了它
revocationReasondeveloperIssue原因客户提到了你 app 中实际存在或被认为存在的问题
revocationReasonother原因退款出于 Apple 未逐项列出的其他原因
revocationReasonupgradedToBundle原因不是退款;该交易因客户切换到订阅捆绑包而被撤销

currentEntitlements 已经会剔除已退款的购买

你并不总是需要自己去读取那些撤销字段。Transaction.currentEntitlements 是客户此刻仍然有权享用的购买序列,Apple 在构建它时就会剔除那些你不应再兑现的项。被 App Store 退款或撤销的产品不会出现在其中。已过期的订阅、以及一旦用完就消失的消耗型商品也不会出现。

这使得 currentEntitlements 成为最干净的访问判断。问它客户拥有什么,就恰好授予那些,退款会替你移除相应权益,而无需任何一次 revocationDate 检查。撤销字段用于你想要细节的时候,也就是日期和原因,用来记录事件或对其作出反应。权益列表用于回答那个朴素的问题:是否继续为其亮灯。

实践中的 StoreKit 2 退款检测:启动时与运行中

你的 app 在设备上有两个时刻可以捕获退款,它们需要不同的代码。一个是在 app 打开时,退款实时发生或在另一台设备上发生。另一个是在启动时,补上关闭期间发生的所有变化。漏掉第二个,你的 StoreKit 2 退款检测就会在大多数退款所落之处正好留下一个漏洞,因为客户在申请退款时很少还开着你的 app。

在启动时就开始监听,否则你会漏掉关闭期间发生的退款

Transaction.updates 是一个异步序列,每当系统在你的 app 之外或另一台设备上创建或更新一笔交易时,它就会发出这笔交易,退款也在其中。Apple 的指示很直接:在你的 app 一启动时就开一个 Task 去迭代它,否则你可能会漏掉那些只在启动时送达一次的交易。一笔在夜间落下的退款,会在 app 下次打开时通过 updates 送达,但前提是已经有一个监听器在运行以接收它。没有监听器,就没有事件,退款会一直不可见,直到别的东西来对账。

同一台设备上的购买不会通过 updates 送达

有一个陷阱会绊倒那些手动测试退款的人。在同一台设备上完成的普通购买不会通过 updates 到达;StoreKit 会直接从购买调用的结果中返回它。updates 用于那些带外变化:退款、Ask to Buy 的批准、兑换码的兑换,以及在别处完成的购买。所以要围绕 updatescurrentEntitlements 来构建你的退款处理,而不是围绕购买流程,因为退款永远不会沿着销售所走的那条路径退回来。

一张揉皱的纸质收据放在昏暗的表面上,上面盖着一枚淡淡的红色印章,代表一笔被 StoreKit 2 用撤销日期标记的已退款交易

客户端检测无法为你做到什么

在设备上读取退款既快又免费,但它有一个上限,假装它没有上限,正是收入流失的方式。设备只知道 StoreKit 告诉它的东西,而 StoreKit 只在你的 app 运行时才说话。一位拿到退款后再也不打开你 app 的客户,就是你的客户端检查永远看不到的客户。

revocationDate 并不总是意味着退款

同一个字段也会因 Family Sharing 而翻转。当客户失去对某笔共享购买的访问权,因为组织者移除了他、或共享结束了,那笔交易同样会得到一个 revocationDate。所以非 nil 的日期意味着客户不再拥有这笔购买,这正是你做访问控制所需要的,但它并不总是意味着有钱退了出去。如果你在为收入统计退款,先把 Family Sharing 的撤销和真正的退款分开,再去相信那个数字。

退款可以被撤销

退款并不总是最终的。Apple 可以撤销它,一旦撤销,交易上的撤销字段会被移除,购买重新有效。如果你在退款时切断了访问,你需要在撤销时把它恢复。在设备上,这会表现为又一个 updates 事件,带着一笔干净的交易;在你的服务器上,它是一条独立的 REFUND_REVERSED 通知。只处理退款,你就会把一位付费客户晾在那里,没有访问权,却握着一张有效的收据。

一次迟来的撤销究竟要花多少钱

退款很少只是售价从你账户里离开。等它结清时,你通常已经为那笔购买实际花掉了真金白银,而这笔支出并不会回来。生成的图片消耗了 GPU 分钟。聊天回答消耗了按 token 计费的模型 API 调用。上传消耗了你至今仍在付费保存的存储。如果这笔购买资助了给某位创作者的付款,那笔钱早已出门。这些都不会随退款而回。

客户端检测会在你仍能控制的那一部分上收窄窗口,也就是未来的支出。你越早知道一笔购买被退款,就越早停止为它提供服务。但设备只在 app 打开时才告诉你,所以一位再也不回来的已退款用户,会保留你在服务器端授予的一切访问权,每当一个后台任务或一台已同步的设备代表他行动时,都在悄悄让你付出代价。客户端让撤销变快,却不能让它有保障。

用客户端求速度,用服务器求真相

干净的设计会让两种信号各展所长。在设备上,Transaction.updatescurrentEntitlements 在已退款客户打开 app 的那一刻给你一个即时的本地反应,适合处理 UI、也适合在无需往返服务器的情况下完成权益变更。在服务器上,App Store Server Notifications V2 会发出一条 REFUND 消息,无论 app 是否会再被打开它都会到达,这是唯一能可靠阻止你的后端在已退款账户上继续花钱的信号。

信号它存在于何处何时触发信任它来
交易上的 revocationDate设备,StoreKit 2你的 app 读取该交易告诉你某笔具体购买被退款或撤销
Transaction.updates设备,StoreKit 2退款在 app 运行时落下,或在启动时(如果你监听)为在场的客户即时作出反应
currentEntitlements设备,StoreKit 2你检查客户此刻拥有什么无需自己追踪退款即可管控访问
REFUND 通知你的服务器,App Store Server Notifications V2Apple 处理退款,无论 app 是否打开停止在一位再也不回来的客户身上的服务器端支出

为拿着手机的客户接入设备信号,为不在场的那位接入服务器通知。退款出现在两个地方是有意为之。只读取其中之一,正是一个已退款账户在销售早已消失后仍持续让你花钱的方式。

常见问题解答

我如何在 StoreKit 2 中检测退款?
检查交易的 revocationDate。对于有效购买它是 nil,一旦 App Store 为该交易退款它就保存一个日期,所以非 nil 的 revocationDate 就是某笔购买被退款或撤销的信号。
revocationDate 和 revocationReason 有什么区别?
revocationDate 是 App Store 收回购买的时间,revocationReason 是原因。当客户提到你 app 中的问题时原因是 developerIssue,其他情况则是 other。
已退款的购买还会出现在 currentEntitlements 中吗?
不会。Transaction.currentEntitlements 会排除被 App Store 退款或撤销的购买,所以已退款的产品会自行从客户的权益中掉出,这使它成为一个安全的访问判断。
StoreKit 会把 app 关闭期间发生的退款告诉我的 app 吗?
只有当你从启动时就监听才会。Transaction.updates 会在启动时把那些变化送达一次,所以你必须在 app 启动时就开一个 Task 去迭代它,否则退款会被漏掉,直到别的东西来对账。
仅靠客户端退款检测就够了吗?
不够。设备只在你的 app 运行时才得知退款,所以一位再也不重新打开 app 的客户对它是不可见的。App Store Server Notifications V2 的 REFUND 才是无论如何都会到达你这里的信号。
revocationDate 是否总是意味着客户被退款了?
不是。当客户通过 Family Sharing 失去某笔购买时也会设置 revocationDate,所以非 nil 的日期意味着他们不再拥有该购买,但并不总是意味着钱被退了回来。

来源和延伸阅读

RefundHalt

App Store 和 Google Play 退款自动驾驶

继续阅读

下一项退款请求已经在路上。

读完又一封关于未能申辩退款的支持邮件所需的时间,足够您完成 RefundHalt 设置。