AppleStore In-App Purchase 訂閱制後端流程

前言

本篇紀錄以後端角度來串接 AppleStore IAP 訂閱制的流程

前置作業

產生 shared-secret 用以驗證收據
參考官方文件 Apple-Developer Generate a shared secret to verify receipts

APP內訂閱流程

Apple-Developer Handling Subscriptions Billing

基本的驗證收據流程如下

當 AppleStore IAP 建立商店訂單,取得加密收據後,呼叫 API 由後端來驗證收據

收據 ex:

{"receipt-data":"MIIUVQY...4rVpL8NlYh2/8l7rk0BcStXjQ=="}

後端收到收據後,參數加上 shared-secret 來呼叫 verifyReceipt 來解析收據

Apple-Developer verifyReceipt
傳送參數 ex:

{
  "password": "f4d35830e3...52aae",
  "receipt-data": "MIIUVQY...4rVpL8NlYh2/8l7rk0BcStXjQ==",
  "exclude-old-transactions": false
}

IOS 有 Production、Sandbox 環境,官方建議收到收據後
都先打 Production 環境 https://buy.itunes.apple.com/verifyReceipt
若收到 status = 21007 則代表是 Sandbox 環境的收據

{"status":21007}

再打 Sandbox 環境 https://sandbox.itunes.apple.com/verifyReceipt 來解析收據
Apple-Developer status

解析收據後,status = 0 用來判斷是否驗證成功

in_app = 未確認訂單的消耗品、訂閱訂單
可檢查 expires_date_ms 來判斷 是否為訂閱訂單
可檢查 product_id 來判斷是否為 訂閱品項

transaction_id = 訂單編號
original_transaction_id = 初始訂單編號

正常流程是 IAP交易成功後 但尚未完成交易的訂單,當後端驗證完收據後,照業務邏輯跑完訂單流程,吐成功狀態回 IOS APP
此時 IOS 會去完成該筆訂單,則下筆訂單解析後的 in_app 就不會出現當前該筆訂單 (因為已經被Finish了)
但訂閱訂單會永久保留在 in_app 裡,若要避免輪巡 in_app 檢查重複訂單造成 DB Loading 的話
可將訂閱訂單 存入快取 ex: Redis HashTable,避免重複到 DB 檢查訂閱訂單是否有建過

註記: 當某一訂閱品項初始訂閱時, transaction_id 會等於 original_transaction_id
當下次同一訂閱品項續訂後,original_transaction_id 則還會是初始訂閱
在 APP 內訂閱時,因為知道是哪個會員訂閱建單的,所以正常
但若是自動續訂時,不會知道是哪個會員訂閱,則需要靠 original_transaction_id 找到對應會員,來走建單流程

當 A會員 APP內初始訂閱後取消訂閱,等訂閱到期後,再由B會員在APP訂閱
=> 以這個情境來看,當收到下期自動續訂,若以 original_transaction_id 來找到對應訂單的會員 是會找到 A會員 而不是 B會員
=> 為了處理這個問題, APP 訂閱後,需要額外多記一張 Mapping 表
=> 當 B 會員在 APP 訂閱後,更新Mapping表,下次自動續訂時用 original_transaction_id 才能找到B會員來建單

original_transaction_id(PK) MemberID
2000000304824152 B會員

解析收據 ex:

{
  "environment": "Sandbox",
  "receipt": {
    "receipt_type": "ProductionSandbox",
    "adam_id": 0,
    "app_item_id": 0,
    "bundle_id": "123",
    "application_version": "123",
    "download_id": 0,
    "version_external_identifier": 0,
    "receipt_creation_date": "2023-03-31 02:27:57 Etc/GMT",
    "receipt_creation_date_ms": "1680229677000",
    "receipt_creation_date_pst": "2023-03-30 19:27:57 America/Los_Angeles",
    "request_date": "2023-03-31 02:28:16 Etc/GMT",
    "request_date_ms": "1680229696727",
    "request_date_pst": "2023-03-30 19:28:16 America/Los_Angeles",
    "original_purchase_date": "2013-08-01 07:00:00 Etc/GMT",
    "original_purchase_date_ms": "1375340400000",
    "original_purchase_date_pst": "2013-08-01 00:00:00 America/Los_Angeles",
    "original_application_version": "1.0",
    "in_app": [
      {
        "quantity": "1",
        "product_id": "123",
        "transaction_id": "2000000304824152", // 某一訂閱品項初始訂閱單
        "original_transaction_id": "2000000304824152",
        "purchase_date": "2023-03-30 07:49:01 Etc/GMT",
        "purchase_date_ms": "1680162541000",
        "purchase_date_pst": "2023-03-30 00:49:01 America/Los_Angeles",
        "original_purchase_date": "2023-03-30 07:49:10 Etc/GMT",
        "original_purchase_date_ms": "1680162550000",
        "original_purchase_date_pst": "2023-03-30 00:49:10 America/Los_Angeles",
        "expires_date": "2023-03-30 07:54:01 Etc/GMT",
        "expires_date_ms": "1680162841000",
        "expires_date_pst": "2023-03-30 00:54:01 America/Los_Angeles",
        "web_order_line_item_id": "2000000024170360",
        "is_trial_period": "false",
        "is_in_intro_offer_period": "false",
        "in_app_ownership_type": "PURCHASED"
      },
      {
        "quantity": "1",
        "product_id": "123",
        "transaction_id": "2000000304992841", // 第二筆訂閱單
        "original_transaction_id": "2000000304824152", // 某一訂閱品項初始訂閱單
        "purchase_date": "2023-03-30 10:49:58 Etc/GMT",
        "purchase_date_ms": "1680173398000",
        "purchase_date_pst": "2023-03-30 03:49:58 America/Los_Angeles",
        "original_purchase_date": "2023-03-30 07:49:10 Etc/GMT",
        "original_purchase_date_ms": "1680162550000",
        "original_purchase_date_pst": "2023-03-30 00:49:10 America/Los_Angeles",
        "expires_date": "2023-03-30 10:54:58 Etc/GMT",
        "expires_date_ms": "1680173698000",
        "expires_date_pst": "2023-03-30 03:54:58 America/Los_Angeles",
        "web_order_line_item_id": "2000000024188245",
        "is_trial_period": "false",
        "is_in_intro_offer_period": "false",
        "in_app_ownership_type": "PURCHASED"
      }
    ]
  },
  "latest_receipt_info": [
    {
      "quantity": "1",
      "product_id": "123",
      "transaction_id": "2000000304992841",
      "original_transaction_id": "2000000304824152",
      "purchase_date": "2023-03-30 10:49:58 Etc/GMT",
      "purchase_date_ms": "1680173398000",
      "purchase_date_pst": "2023-03-30 03:49:58 America/Los_Angeles",
      "original_purchase_date": "2023-03-30 07:49:10 Etc/GMT",
      "original_purchase_date_ms": "1680162550000",
      "original_purchase_date_pst": "2023-03-30 00:49:10 America/Los_Angeles",
      "expires_date": "2023-03-30 10:54:58 Etc/GMT",
      "expires_date_ms": "1680173698000",
      "expires_date_pst": "2023-03-30 03:54:58 America/Los_Angeles",
      "web_order_line_item_id": "2000000024188245",
      "is_trial_period": "false",
      "is_in_intro_offer_period": "false",
      "in_app_ownership_type": "PURCHASED",
      "subscription_group_identifier": "21277885"
    },
    {
      "quantity": "1",
      "product_id": "123",
      "transaction_id": "2000000304824152",
      "original_transaction_id": "2000000304824152",
      "purchase_date": "2023-03-30 07:49:01 Etc/GMT",
      "purchase_date_ms": "1680162541000",
      "purchase_date_pst": "2023-03-30 00:49:01 America/Los_Angeles",
      "original_purchase_date": "2023-03-30 07:49:10 Etc/GMT",
      "original_purchase_date_ms": "1680162550000",
      "original_purchase_date_pst": "2023-03-30 00:49:10 America/Los_Angeles",
      "expires_date": "2023-03-30 07:54:01 Etc/GMT",
      "expires_date_ms": "1680162841000",
      "expires_date_pst": "2023-03-30 00:54:01 America/Los_Angeles",
      "web_order_line_item_id": "2000000024170360",
      "is_trial_period": "false",
      "is_in_intro_offer_period": "false",
      "in_app_ownership_type": "PURCHASED",
      "subscription_group_identifier": "21277885"
    }
  ],
  "latest_receipt": "MIIuVQYJ...Ytr0Nq",
  "pending_renewal_info": [
    {
      "expiration_intent": "1",
      "auto_renew_product_id": "123",
      "is_in_billing_retry_period": "0",
      "product_id": "123",
      "original_transaction_id": "2000000304824152",
      "auto_renew_status": "0"
    }
  ],
  "status": 0
}

後端建單成功後,API 吐成功狀態讓 IOS APP 確認訂單

自動續訂訂閱流程

IOS 有提供 Server to Server 的 API 通知,但需要先設定好 正式環境、沙箱環境的 API 路徑
當訂單自動續訂時,IOS 會呼叫我方 API 進行通知

Apple-Developer 輸入用來接收 App Store 伺服器通知的 URL

Apple-Developer App Store Server Notifications V1
Apple-Developer App Store Server Notifications V1 notification_type

以 notification_type 來判斷通知類型
當後端處理完通知流程後,需回應 Http status 200 表示處理成功,否則 IOS Server 會有 Retry 機制
Apple-Developer Responding to App Store Server Notifications

訂閱通知

notification_type = INITIAL_BUY (初次訂閱)、DID_RENEW (續訂成功)、INTERACTIVE_RENEWAL (商店頁訂閱)

INITIAL_BUY 可不處理,因首次訂閱會在APP內,由 APP 自行通知後端 API 建單即可

收到 DID_RENEW、INTERACTIVE_RENEWAL後,代表訂閱成功
最新一筆訂閱單可抓取 unified_receipt.Latest_receipt_info,但因為要避免後端漏建單問題,還是採取輪巡 In_app 的方式
所以需要把 unified_receipt.latest_receipt 最新加密的收據,用來呼叫 Apple-Developer verifyReceipt
取得 In_app 後,則依 上面 APP 訂閱流程方式處理自動續訂訂單即可

取消訂閱、恢復訂閱通知

notification_type = DID_CHANGE_RENEWAL_STATUS (訂閱狀態變更)

判斷 auto_renew_status = true (恢復訂閱)、false (取消訂閱)
但因無法得知確切是哪一筆 transcation_id 的訂閱狀態變更了,只能透過 original_transaction_id 來往 DB 尋找最新一筆訂閱單來更新狀態

退款通知

notification_type = REFUND (退款)

輪巡 unified_receipt.Latest_receipt_info,抓 transaction_id 來判斷哪一筆訂單,更新為退款狀態,並做相對應處置

扣款失敗通知

notification_type = DID_FAIL_TO_RENEW (扣款失敗)

過去未成功續訂的單已成功續訂

notification_type = DID_RECOVER

當扣款失敗後,IOS 會進行Retry 嘗試扣款,在嘗試一段時間且失敗多次後會停止Retry。

During the 24-hour period before the subscription expires, the App Store starts trying to renew it automatically. The App Store makes several attempts to automatically renew the subscription over a period of time but eventually stops if there are too many failed attempts.

備註

IOS 扣款通知會在續訂到期時前 24 小時內嘗試扣款,若扣款成功則會直接通知 DID_RENEW,要確保我方服務這邊的訂閱商品起訖時間是否可銜接得起來
而雖然 IOS 會提前通知,但實際上收據內的購買時間還是為原本的續訂到期時間,不會提前


轉載請註明來源,若有任何錯誤或表達不清楚的地方,歡迎在下方評論區留言,也可以來信至 leozheng0621@gmail.com
如果文章對您有幫助,歡迎斗內(donate),請我喝杯咖啡

斗內💰

×

歡迎斗內

github