Entitlements

退款后收回访问权限的工作,Apple 和 Google 都不会替你完成

在这两个商店中,资金流动和访问权限终止是两件独立的事,而且只有一件会自动发生。Google 将权限移除功能设为默认关闭的选项;Apple 则无法处理已经被消耗掉的消耗型商品。你的服务器如果没有处理到的部分,就会继续消耗你已经不再拥有的收入。

5 min read
退款后收回访问权限的工作,Apple 和 Google 都不会替你完成

在这两个平台上,资金流动和访问权限终止都是两个独立的事件,而且只有一个会自动发生。Google 将权限移除功能放在一个默认关闭的选项后面。Apple 可以处理授权(licence),但无法处理已经被用户消耗掉的消耗型商品。你的服务器未能清理掉的部分,会继续消耗你已经不再拥有的收入。

关键要点

  • 除非你另行指定,为 Play 订单退款并不会改变客户的访问权限。你需要在 Play Console 中勾选「Remove entitlement」,或者在 API 调用中携带 revoke=true 参数。这两件事都不会自动发生。

  • 未启用 revoke 选项的退款,完全不会出现在 Voided Purchases API 中。任何仅基于该数据源构建的撤销权限任务,都会悄无声息地遗漏掉每一笔这样的退款。

  • 对于非消耗型商品和订阅,Apple 可以代你撤销其授权。而消耗型商品一旦被消耗就被视为已交付,这让 StoreKit 没有任何东西可以撤销,因此扣减余额的工作就落在了你自己的服务器上。

  • Apple 有时会撤回自己批准的退款。REFUND_REVERSED 通知表示某笔已批准的退款被撤销了,revocationDate 会恢复为 null,而你之前移除的所有内容都需要恢复。

  • Google 的 voidedReason 字段共有九种不同的取值,涵盖从 remorse(反悔)、accidental_purchase(误购)到 fraud(欺诈)、friendly_fraud(善意欺诈)以及 unacknowledged_purchase(未确认购买)等情况,因此同一个数据源既能告诉你何时该切断访问权限,也能标记出哪些情况疑似滥用。

  • 只要一笔已退款的购买仍保留访问权限,你就是在用早已退还给买家的收入,为它的计算资源、存储和 API 调用买单。

为什么退款后访问权限默认依然存在

两家公司都把付款和权限(entitlement)视为两个独立的操作。付款遵循商店政策,一旦获批,退款就会结算,账本也会随之更新。权限则是另一回事,有时是可选的,有时需要你自己处理,很多时候这两种情况同时存在,具体取决于平台和商品类型。两家商店都不会想当然地认为退还资金就意味着终止访问权限,因为很多合理的情形——部分退款、善意补偿、客服的让步姿态——本意就是让客户继续持有他们购买的东西。

Google Play:那个被忽视的勾选框

Play Console 将退款拆分为两个决定。首先是金额,然后是完全独立的另一个决定:是否勾选「Remove entitlement」。大多数客服人员都不会碰这个选项,这也合理,因为大多数退款请求确实出于善意,客户本就应该保留商品。确认界面从未提及的是:正是这个未被勾选的选项,让某人在你本以为已经了结的退款之后,依然持有订阅或应用内商品。

API 中存在同样的陷阱

代码层面也存在完全相同的分叉。orders.refund 接口有一个可选的 revoke 查询参数,将其设为 true 会立即终止对该商品的访问权限,如果涉及经常性订阅,还会连带取消后续的每一次付款。如果不传这个参数,默认值就是 false:退款会正常完成,购买记录会发生变化,而客户则会完全照常继续使用。

  • 在 Play Console 中退款且不勾选「Remove entitlement」,访问权限会继续保留。该订单也不会出现在 Voided Purchases API 中。

  • 在 Play Console 中退款且勾选「Remove entitlement」,访问权限会立即终止。该订单会出现在 Voided Purchases API 中。

  • 调用 orders.refund 且不传 revoke 参数,默认值为 false,访问权限会继续保留。同样,不会有任何记录出现在 Voided Purchases API 中。

  • 调用 orders.refund 并传入 revoke=true,访问权限会立即终止,该订单也会出现在 Voided Purchases API 中。

一个只读取 Voided Purchases API 的撤销权限任务,永远不会发现未启用 revoke 标志的退款。这条记录不是延迟到达,它根本就不会到来,该订单会一直停留在这个数据源之外,就像退款从未发生过一样。

正是最后那一行导致了集成方案的失效。不必手动盯着 Play Console 就能构建撤销权限流程,这正是 Voided Purchases API 存在的意义,但它的范围仅限于真正带有撤销操作而失效的订单。未勾选该选项、或未传入 revoke=true 的退款,在这里是设计上就不可见的,而不是出于某种缺陷。

理解 voidedReason

已失效的购买记录会附带一个 voidedReason 字段,用来说明原因,共有九种可能的取值:other(其他)、remorse(反悔)、not_received(未收到)、defective(有缺陷)、accidental_purchase(误购)、fraud(欺诈)、friendly_fraud(善意欺诈)、chargeback(拒付)和 unacknowledged_purchase(未确认购买)。只把它当作单纯的记录数据,就是在浪费这个字段。remorse 和 accidental_purchase 描述的是普通、不值得特别关注的退款。而 fraud、friendly_fraud 和 chargeback 则应该进入欺诈审核队列,而不是自动撤销权限并了结,因为出现这些值的账号在统计上会以更高的概率重复发生。

任何直接对接这个数据源的开发者都需要牢记一个运营数字:purchases.voidedpurchases.list 每天允许 6,000 次查询,并且任意 30 秒内不超过 30 次。频密的轮询循环会很快耗尽这个配额。按计划、配合分页令牌进行定时拉取,才是它本来的设计方式。

Apple:授权可以撤销,消耗型商品不可以

App Store Server Notifications V2 会在退款结算的瞬间发出 REFUND 通知,不加区分地覆盖消耗型商品、非消耗型商品、自动续订订阅和非续订订阅。但接下来应该做什么,则完全取决于商品类型,因为这四种类型中只有两种属于 StoreKit 自身的撤销机制管辖范围。

消耗型商品为何不在其中

持续性的权限属于非消耗型商品和订阅,Apple 的系统可以关闭这些权限。消耗型商品则没有这种东西。一旦被消耗,它就被标记为已交付,这让 StoreKit 没有任何东西可以收回。一位购买了 500 枚金币、花掉之后又拿到退款的客户,在 Apple 看来已经获得了全部商品。只有你自己的账本上,那些金币才还存在。那条 REFUND 通知,就是提示你自己去找到对应的原始交易 ID 并扣减余额的唯一信号,因为 Apple 那一端不会替你做这件事。

  • REFUND 表示某笔消耗型、非消耗型、自动续订或非续订的购买已被退款。请立即撤销非消耗型商品和订阅的访问权限,并自行扣减任何消耗型商品的余额。

  • REFUND_DECLINED 表示 Apple 拒绝了客户的退款请求。无需采取任何行动,因为访问权限本来就没有受到影响。

  • REFUND_REVERSED 表示 Apple 此前批准的一笔退款被撤回了。请恢复最初那条 REFUND 通知让你移除的任何内容。

  • CONSUMPTION_REQUEST 表示 Apple 需要你提供证据后再做决定。请在 12 小时内回复,并注意这条通知是在 REFUND 之前到达,而不是之后。

几乎所有人都会忽略的那条通知

REFUND_REVERSED 正是各种集成方案经常遗漏的那一个,因为它代表 Apple 撤销了自己之前做出的决定,而这通常不是任何人在最初设计时会考虑到的情况。它一旦到达,revocationDate 就会恢复为 null,意味着 Apple 现在再次认为这笔购买完全有效。你的服务器在最初那条 REFUND 通知中所做的一切——删除权限、取消订阅记录、将消耗型商品余额清零——都需要撤销。

不要指望一笔被撤回的退款会表现得像一笔全新的购买,因为事实并非如此。已施加的封禁、已撤销的访问权限、已标记为消耗的余额:REFUND_REVERSED 正是要求你准确撤销这些操作的指令,之后不会再有任何后续通知来提醒你。

一步步补上这个缺口

  • 在 Play 的所有退款途径中——无论是 Console 还是 API——都将 revoke=true 设为默认做法,只在确实需要让客户保留访问权限的客服场景中才保留另一种选择。

  • 按计划、配合分页令牌定时拉取 purchases.voidedpurchases.list,而不是采用紧密循环的方式,并将请求量控制在每天 6,000 次、每 30 秒 30 次以内。

  • 将 voidedReason 为 fraud、friendly_fraud 和 chargeback 的情况送入审核队列,而不是自动撤销权限并了结处理。

  • 在 Apple 侧订阅 App Store Server Notifications V2,并在 REFUND 通知识别出非消耗型商品和订阅后,立即撤销相应的访问权限。

  • 为每一笔消耗型商品购买都保存对应的原始交易 ID,这样当 REFUND 通知到达时,就能准确定位需要扣减的余额,而无需任何人手动查找。

  • 有意识地实现对 REFUND_REVERSED 的处理,并针对每笔撤销操作记录其对应的交易 ID 或订单 ID,这样一旦出现撤回,就能精确恢复之前被拿走的内容。

这个缺口实际造成的代价

这里的问题不在于对客户是否公平。问题在于,收入已经不在了,但有些东西还在继续运行。一个已退款的订阅仍在调用你的 API、仍占用着数据、仍在消耗计算资源,这在道义上没有任何争议;它带来的是一张账单。你的基础设施,正是靠着那笔你已经退还的收入在支付。两家商店都不会替你追踪这笔支出,因为在它们看来,退款在资金转移的那一瞬间就已经结束了。管理访问权限这一侧的事,完全是你自己的开支,需要你自己来管理。

因此,Refund Sensor 将撤销权限视为退款处理流程的一部分,而不是事后附加的功能。REFUND 事件及其对应的访问权限变更会作为一个整体操作写入系统,而 REFUND_REVERSED 事件则会自动恢复到之前的状态,不必等到有人注意到某个客户被挡在了 Apple 几天前就已经归还的东西之外。

这些规则的官方文档出处

常见问题

不会。只有在 Play Console 中勾选了「Remove entitlement」,或者 orders.refund 调用携带了 revoke=true 参数时,访问权限才会终止。由于这两者都不是默认设置,退款经常已经完成,但客户仍在继续使用产品。

系统会发出 REFUND 通知,但 StoreKit 无法撤销消耗型商品,因为消耗它就等同于已完成交付。找到原始交易并扣减相应余额,这项工作需要由你自己的服务器来完成。

它表示 Apple 之前批准的一笔退款已被撤回。revocationDate 会重新变回 null,该购买项也再次被视为有效。无论最初的 REFUND 通知让你移除了哪些访问权限或余额,都必须恢复,而许多集成方案根本没有订阅这个通知。

因为该接口只返回带有撤销(revocation)操作的已失效订单。未启用 revoke 选项处理的退款不会包含在内,因此任何仅依赖该数据源的处理流程都会完全忽略这些退款。

每天最多 6,000 次查询,并且任意 30 秒窗口内不超过 30 次。该接口的设计初衷是配合分页令牌进行定时拉取,而不是持续轮询。

#refunds#entitlements#voided purchases#refund_reversed#revoke access#play billing
Refund SensorRefund Sensor TeamRefund defense for App Store and Google Play developers