Open9
【iOS】サブスク実装調査メモ(Storekit 2, Server Notifications V2)
概要
- iOSモバイルクライアントアプリと、サーバーのあるアプリで、サブスクリプションを実装したい
参考
- クライアント実装のサンプルコード: https://developer.apple.com/documentation/storekit/in-app_purchase/implementing_a_store_in_your_app_using_the_storekit_api
公式動画による全体像の把握
初回のサブスクリプション時(動画の21:25-)
下2つの矢印は任意と思われる
- iOSアプリは、App Store Serverから受け取ったSigned transaction infoについて
- 端末上でverifyしてoriginalTransactionIdとその他ほしい値をサーバーに送信する、または、
- Signed transaction infoとして自社サーバーに送信し、サーバーでverifyし、必要な値をDBに保存する、のでもよい。
- Server notificationの中のsigned transaction infoがapp account tokenを含んでいるので、in-app userと紐づけることができる
- いつでも、/inApps/v1/subscriptionsのAPIにoriginalTransactionIdを送信して、情報を確認できる状態になる。
renewに成功した時
下2つの矢印は任意と思われる
- notificationのpayloadの中の、signed transaction infoとsigned renewal infoを見て、次のサブスクのrenewal dateや、顧客の次のrenewelのrenewal preferencesについて確認することができる。
renewに失敗し、grace period(猶予期間)を終えても失敗した時
- grace periodを有効化するかを選べるかも?
renewに失敗し、grace periodに復活した場合
Server Notifications V2
-
https://developer.apple.com/documentation/appstoreservernotifications/enabling_app_store_server_notifications
- HTTPSのURLを登録して。Sandboxでも同じURLを登録してOK
- ver2でも1でも良いが、新規実装は2にして。
- Server NotificationsのTestもできる。
- App Store Connectの設定変更の反映にはタイムラグがあるという記事を見つけた
Receivingについて
- https://developer.apple.com/documentation/appstoreservernotifications/receiving_app_store_server_notifications
- iapについて、HTTP POSTを使ってJSONオブジェクトをあなたのサーバーに送信するよ。
- POSTのbodyには、responseBodyV2のデータが入っている。
- responseBodyV2には、JWS形式で署名されたsignedPayload(The signedPayload is a string of three Base64 URL-encoded components, separated by a period.)が入っている
- 読み取り方法は詳しくはこちら
- モンストの記事も参考になる
- Apple PKIからG3 Root証明書を取得して使う。
- Apple側が秘密鍵で署名しているので、読み取る側は公開鍵を使えばOK(悪意のある人はAppleの秘密鍵を持っていないので大丈夫)
- ではSubscriptionKey_xxxx.p8はいつ使うの?→App Store Server APIを叩く際には、SubscriptionKey_xxxxx.p8を使い、リクエストの作成・レスポンスの読み取りを行う。今回はサーバー通知の読み取りだから公開鍵を使えばOK。
- signedPayloadを読み取ると、responseBodyV2DecodedPayloadになる。
-
notificationType
、subtype
、data
を含む。 -
data
の中に、signedTransactionInfo
やsignedRenewalInfo
がある。これらJWS表記であり、デコードする必要がある。 -
daraのAPI doc
公式docのスクショ
動画のスクショ(一部プロパティが省略されている)
-
- サーバー通知を保存するときは、notificationUUIDでuniqueとなるように制御する
- サーバー通知に対して200を返さなかった場合は何度もリトライしてくれる。そのたびにレコードが重複して作成されるのを防ぐため。
Respondingについて
- https://developer.apple.com/documentation/appstoreservernotifications/responding_to_app_store_server_notifications
- AppleからServer Notificationを受け取ったら、自社サーバーも200または50x, 40xのステータスコードを返すこと。
- dataを返す必要はない
- 最初の通知の際に200を返さなかったら、Appleは5回リトライしてくれる(本番環境だけね)
-
it retries five times, at 1, 12, 24, 48, and 72 hours after the previous attempt.
-
- 自分でApp Store Server APIを叩くなどで解決することもできる。
Test in Sandbox
- Sandox専用の通知URLを設定可能
- 購入履歴を削除できる
- 更新頻度を調整できる(月更新を15分にしたり)
https://appstoreconnect.apple.com/access/users/sandbox
JWSTransactionDecodedPayload
- サーバー通知から受け取ったdataの中の、signedTransactionInfoをdecodeしたもの。この中に含まれている情報の中に、サブスクリプションを管理する上で重要な情報が含まれている。
- 公式リファレンス
重要そうなプロパティを抜粋
名前 | 公式の解説 |
---|---|
appAccountToken | A UUID you create at the time of purchase that associates the transaction with a customer on your own service. |
expiresDate | The UNIX time, in milliseconds, that the subscription expires or renews. |
isUpgraded | A Boolean value that indicates whether the customer upgraded to another subscription. |
originalPurchaseDate | The UNIX time, in milliseconds, that represents the purchase date of the original transaction identifier. |
originalTransactionId | The transaction identifier of the original purchase. |
productId | |
purchaseDate | The UNIX time, in milliseconds, that the App Store charged the user’s account for a purchase, restored product, subscription, or subscription renewal after a lapse. |
subscriptionGroupIdentifier | |
transactionId | |
transactionReason | The reason for the purchase transaction, which indicates whether it’s a customer’s purchase or a renewal for an auto-renewable subscription that the system initiates. |
問
- App Storeから「一度契約して終了したサブスクリプション」を再度契約したとき、originalTransactionIdは過去のものと同じになる?
- 追記: 実際に運用した結果、同じものになった。
- App Storeからのプラン変更は、
- 現在有効なサブスクリプションがある場合に、変更することができる?
- 追記: 実際に運用した結果、変更することができる。
DID_CHANGE_RENEWAL_PREF
のサーバー通知が届く
- 追記: 実際に運用した結果、変更することができる。
- 現在有効なサブスクリプションがないが昔契約していたサービスに対して、別のプランで契約することができる?
- 現在有効なサブスクリプションがある場合に、変更することができる?
ChatGPTの回答べたばり
- 一度終了したサブスクをストアアプリから再度契約する時
- Apple (App Store): originalTransactionIdは変わりません。ユーザーが同じサブスクリプションを再購入しても、このIDは一貫して同じままです。
- Google (Play Store): purchase tokenは変わります。サブスクリプションを再購入するたびに新しいトークンが生成されます。
- 有効なサブスクリプションが自動更新された時
- Apple (App Store): originalTransactionIdは、サブスクリプションが自動更新されても変わりません。
- Google (Play Store): purchase tokenも、サブスクリプションが自動更新されても変わりません。
Server Notifications V2のnotificationTypeごとのハンドリング
- 公式docに、ハンドリングの例が書かれている。subtypeと合わせて検証する模様。
- 以下、自分なりの解釈を抜粋する。
- OfferやFamily Sharing、subscriptionのprice increaseは今回の調査には含めない。
- 表の--以下はちょっとマイナーなユースケースとして想定
状況とNotificationType / subtype
購読が成功した時
状況 | NotificationType | subtype |
---|---|---|
最初に購読した時 | SUBSCRIBED | INITIAL_BUY |
自動更新に成功した時 | DID_RENEW | |
-- | -- | -- |
失効したサブスクグループの中のいずれかを再購読した時 | SUBSCRIBED | RESUBSCRIBE |
顧客がサブスクリプションの内容を変更した時
状況 | NotificationType | subtype |
---|---|---|
App Storeから購読を解約した時 | DID_CHANGE_RENEWAL_STATUS | AUTO_RENEW_DISABLED |
購読の解約をキャンセルした時(自動更新されるように設定し直した時) | DID_CHANGE_RENEWAL_STATUS | AUTO_RENEW_ENABLED |
-- | -- | -- |
同じサブスクグループでdown/upgrdeした時 | DID_CHANGE_RENEWAL_PREF | DOWNGRADE / UPGRADE |
ダウングレードをキャンセルし、元の購読に戻る時 | DID_CHANGE_RENEWAL_PREF |
失効や更新失敗系
状況 | NotificationType | subtype |
---|---|---|
顧客が解約を選択して、expireしたとき | EXPIRED | VOLUNTARY |
billing retry期間が回復せずに終了してexpireしたとき | EXPIRED | BILLING_RETRY |
更新失敗し、billing retry periodに入った時 | DID_FAIL_TO_RENEW | |
更新失敗し、billing retry periodに入った時 with Billing Grade Period enabled | DID_FAIL_TO_RENEW | GRACE_PERIOD |
billing retrが成功した時 | DID_RENEW | BILLING_RECOVERY |
Billing Grace Periodを抜けたとき(billing retryに続く) | GRACE_PERIOD_EXPIRED | |
-- | -- | -- |
開発者がサブスクを削除してexpireしたとき | EXPIRED | PRODUCT_NOT_FOR_SALE |
refunds(払い戻し)系
状況 | NotificationType | subtype |
---|---|---|
Appleがrefundsするとき | REFUND | |
顧客からの異議申し立てで、Appleがrefundsを取り消す時 | REFUND_REVERSED | |
appからrequest refund APIを通して行われた、顧客からのrefundを、Appleが断る時 | REFUND_DECLINED | |
顧客からのrefund要求のために、Appleがcunsumption informationを要求する時 | CONSUMPTION_REQUEST |
developerがサブスクの更新期日を延長する系
状況 | NotificationType | subtype |
---|---|---|
特定のサブスクの更新日の延長が成功した時 | RENEWAL_EXTENDED | |
認められたサブスクライバーsに対して、更新日の延長が成功した時 | RENEWAL_EXTENSION | SUMMARY |
特定のサブスクライバーに対しての更新日の延長が失敗した時 | RENEWAL_EXTENSION | FAILURE |
NotificationType別でハンドリング整理
- ハンドリングが必要なNotificationTypeについてのみ整理している。ハンドリング補足に何も書かれていない場合でもDB側に変更を加える必要はある。
NotificationType | ハンドリング補足 |
---|---|
SUBSCRIBED | subtypeを確認(INITIAL_BUY / RESUBSCRIBE ) |
DID_RENEW | |
DID_CHANGE_RENEWAL_STATUS | subtypeを確認(AUTO_RENEW_DISABLED / AUTO_RENEW_ENABLED ) |
EXPIRED | subtypeを確認(VOLUNTARY / BILLING_RETRY / PRODUCT_NOT_FOR_SALE ) |
実際のデータで確認
- transactionIdは下3桁をのみ表示している
購入→更新をし続ける
- サーバー通知
SUBSCRIBED/INITIAL_BUY
で購入 - originalPurchaseDateやoriginalTransactionIdの値はその後もずっと変わらない。
- 新たに購入されたtransactionIdは、originalTransactionIdとは異なる
購入→解約→期限切れ
- サーバー通知
DID_CHANGE_RENEWAL_STATUS/AUTO_RENEW_DISABLED
で、解約したことが分かる - 解約したまま、expiresDateを迎えると サーバー通知
EXPIRED/VOLUNTARY
となる。
購入→ 解約 → 期限切れ → 再び同じプランに加入
- (172行目)
SUBSCRIBED/RESUBSCRIBE
で再び加入している。
購入→ 解約 → 期限切れ → 再び別のプランに加入
- (21行目)
SUBSCRIBED/RESUBSCRIBE
で再び加入している。 - productIdは異なっているが、originalTransactionIdは変わらない。
解約を取り消す
-
DID_CHANGE_RENEWAL_STATUS/AUTO_RENEW_ENABLED
で、再度更新するようにしている。
更新に失敗する
- 更新に失敗したタイミングで、
DID_FAIL_TO_RENEW/GRACE_PERIOD
が届く- このタイミングでは、利用権を剥奪しない
- さらにそのまま更新が成功せず、
GRACE_PERIOD_EXPIRED
が届く- このタイミングで、利用権を剥奪する
- 復活すると、
DID_RENEW/BILLING_RECOVERY
が届く
GRACE_PERIOD_EXPIREDにならずに復活するケース
GRACE_PERIOD_EXPIREDになってから復活するケース
返金処理関連
- REFUNDされているケース
更新するプランを変更した時
-
DID_CHANGE_RENEWAL_PREF/DOWNGRADE
が届き、次の更新のタイミングで別のプランがDID_RENEW
されている。