所有文章
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。