iOS の自動更新サブスクリプションのステータス変更通知を Google Spreadsheetで受け取ってみた

ご好評いただいた[Google Spreadsheet を簡易 Webサーバーとして動かして、手軽にWebHookを受け取る方法](https://qiita.com/kunichiko/items/7f64c7c80b44b15371a3) の具体的な応用例として、iOS の Auto-renewable Subscriptionsの購読ステータスの状態変更を Google Spreadsheetで受け取ってみたので、その顛末をご紹介します。

# iOS の Auto-renewable Subscriptionsの購読ステータスの状態変更が、サーバで受信できるようになりました

恥ずかしながらWWDC 2017をちゃんと調べきれていなくて知らなかったのですが、iOS の Auto-renewable Subscriptionの購読ステータスの状態変更が、サーバで受信できるようになりました。長い間これができなくて困っていたのですが、ようやく少し改善しました(とはいえまだまだ Google Playの方が扱いやすいですが……)。

# どういうこと？
何を言っているかわからない方のために、すこし背景をご説明します。

## iOSの Auto-renewable Subscriptionsとは？

Apple Musicなどのように、「月額xxx円を支払うと音楽聴き放題」みたいな仕組みを iOSのアプリで実現するためのものです。

[弊社](http://www.ripplex.com)が提供する[Scene](http://www.scn.jp/ja/)には「Scene プレミアム」というプレミアムプランがあり、月額料金をお支払いいただくとアルバムに動画を入れられるようになるなどの恩恵を得られるようになります。この月額料金の徴収は、Appleが提供しているアプリ内決済の一つである **Auto-renewable Subscriptions** を利用して実現しています。

## Auto-renewable Subscriptions の罠

Appleの制約で、こういったサービスの決済を事業者が独自行うことができないため、Auto-renewable Subscriptions を使わざるを得ません。にも関わらず、事業者側の自由度が低く、取り扱いに困る仕様になっています。

特に以下のような制約で、解約や返金などの対応が非常にやりにくいという問題があります(一部過去形)。

* ユーザーへのクレーム対応などのためであっても、返金を事業者が自由に行うことができない
* アプリをアンインストールされたり、サービスの解約を行なっても Auto-renewable Subscriptions の解約は、ユーザーが 別途 iTunesの上で行う必要がある
* iTunes上で解約を行なっても事業者はそれを調べることができず、**「次月に更新に失敗した」**という事実でしか判定できなかった(過去形)

特に困るのが、ユーザーがサービスを解約しても事業者は課金を止めることができず、ユーザーに注意を促すしかないというところです。仕方なく **iTunesで自動更新を解約しておかないとサービスの解約ができない** という仕様にすることも考えたのですが、事業者は自動更新が解約されたかどうかすら判断できないので、「１ヶ月後にもう一度お試しください」と言うしかないという、なかなか辛い仕様でした。

## WWDC2017での仕様変更

Appleもこの状況を理解しているようです。そのため、昨年の WWDC2017で仕様が少し拡張され、いくつかの問題が改善されました。

* レシート(Appleから事業者に渡されるデジタルデータ)の中に以下のステータスが追加された
    * Auto-Renew Status
        * → このステータスを見れば、ユーザーが購読解除を行ってくれたかどうかが判定できる
    * Auto-Renew Preference
    * Price Consent Status
    * Subscription Retry Flag
    * Expiration Intent
    * Cancellation Reason
* ユーザーが購読更新に失敗したり、その後再購読を開始した際に、Appleのサーバーから事業者のサーバーに通知を送ってもらえるようになった (**Server-to-Server Notifications**)

詳細は WWDC 2017の「[Advanced StoreKit](https://developer.apple.com/videos/play/wwdc2017/305/)」 というセッションのビデオをご覧いただければとおもいます。

そして、本記事では今回の改善策の一つである「**Server-to-Server Notifications**」について調べたことを書いております。

## Server-to-Server Notificationsで解決できること

例えば、過去に購読をやめたユーザーが再購読した場合を考えます。ユーザーがアプリのUIから再購読を行ってくれれば良いのですが、iTunesの管理画面から再購読を行ったりすることもできてしまうため、これまでは事業者側がそれにすぐに気がつくことができないという問題がありました。

これまでは、事業者側のサーバーが定期的に Appleのサーバーに問い合わせる必要があったわけです。

**【今まで】**
![before.png](https://qiita-image-store.s3.amazonaws.com/0/41616/511af024-fff6-3bdb-d5f2-433c82e9a08c.png)

今回、Server-to-Server Notificationsが実装されたことにより、こちらからポーリングせずとも、以下のようにAppleのサーバーから通知を行ってくれるようになりました！

**【これから】**
![after.png](https://qiita-image-store.s3.amazonaws.com/0/41616/2bf2a53f-5815-7e69-ccd0-0cb2b752b018.png)

# Server-to-Server Notificationsを Google Spreadsheetで受信してみよう

Appleからの通知はいわゆるWebHookと呼ばれるもので、HTTPの POSTリクエストでJSONを送ることで実現されています。

通知で送られるJSONの詳細は [In-App Purchase Programming Guide](https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/StoreKitGuide/Chapters/Subscriptions.html#//apple_ref/doc/uid/TP40008267-CH7-SW13)の「Status Update Notifications」のところで解説されています。しかし、ドキュメントだけを見ても実際の挙動はなかなか理解しづらいので、実際に通知を受け取ってみちゃったほうが早そうです。

最終的にはアプリケーションサーバーにこの機能を組み込むことになりますが、その実装前の調査のために、[Google Spreadsheet を簡易 Webサーバーとして動かして、手軽にWebHookを受け取る方法](https://qiita.com/kunichiko/items/7f64c7c80b44b15371a3) を使い、Appleからの通知を直接 Google Spreadsheetで受信してみました。

以下、その手順となります。

## 1. Spreadsheetとスクリプトの準備
[Google Spreadsheet を簡易 Webサーバーとして動かして、手軽にWebHookを受け取る方法](https://qiita.com/kunichiko/items/7f64c7c80b44b15371a3) を読んでいただき、POSTリクエストを受け付けられるスプレッドシートを作成してください。

## 2. スクリプトの改良
Appleから送られる JSONは、以下のような階層構造になっています。

```json
{
  "latest_receipt":"...."
  "latest_receipt_info": {
    "product_id": "xxxxx",
    "original_purchase_date_pst": "2017-02-14 20:31:56 America/Los_Angeles",
    "is_in_intro_offer_period": "false",
    "purchase_date_ms": "1520320479000",
    "unique_identifier": "2aea8a.....",
    "original_transaction_id": "100000027.....", 
      :
      :
  },
  "environment": "Sandbox",
  "auto_renew_status": "true",
  "password": "....",
  "auto_renew_product_id": "...",
  "notification_type": "RENEWAL"
}
```

前回の記事のスクリプトでは、latest_receipt_infoの下の product_idなどのような多段になった下の階層のプロパティを分解して表示することができなかったので、以下のように少しスクリプトを修正しました。

```js
// POSTリクエストに対する処理
function doPost(e) {
  // JSONをパース
  if (e == null || e.postData == null || e.postData.contents == null) {
    return;
  }
  var requestJSON = e.postData.contents;
  var requestObj = JSON.parse(requestJSON);

  //  
  // 結果をスプレッドシートに追記
  //

  var ss = SpreadsheetApp.getActive()
  var sheet = ss.getActiveSheet();

  // ヘッダ行を取得
  var headers = sheet.getRange(1,1,1,sheet.getLastColumn()).getValues()[0];

  // ヘッダに対応するデータを取得
  var values = [];
  for (i in headers){
    var header = headers[i];
    var val = "";
    switch(header) {
      case "date":
        val = new Date();
        break;
      case "mimeType":
        val = e.postData.type;
        break;
      case "length":
        val = e.postData.length;
        break;
      default:
        // それ以外の場合は、ヘッダの値を"ドット記法で表現されたプロパティのパス」と解釈して、指定された値を取得
        var path = header.split('.');
        var obj = requestObj
        for (j in path) {
          obj = obj[path[j]];
          if (obj == null) {
            break;
          }
        }
        if ( obj == null ) {
          val = "";
        } else {
          val = obj;
        }
        break;
    }
    values.push(val);
  }

  // 行を追加
  sheet.appendRow(values);
}
```

これによって、たとえばスプレッドシートのヘッダに「`latest_receipt_info.product_id`」のようにドット記法で階層構造のプロパティを指定できるようになりました。

## 3. スプレッドシートのヘッダ行を変更

2の変更により、階層下のプロパティを分解できるようになったので、スプレッドシートのヘッダーを以下のように設定します。

![スクリーンショット 2018-03-07 20.12.30.png](https://qiita-image-store.s3.amazonaws.com/0/41616/65d0293a-32a4-c2c9-468e-4fa8a5e679ac.png)

項目数が多すぎて入りきらないので、ヘッダ行の全項目を列挙しておきます。

* date
* mimeType
* length
* 
* environment
* notification_type
* password
* original_transaction_id
* cancellation_date
* web_order_line_item_id
* auto_renew_status
* auto_renew_adam_id
* auto_renew_product_id
* expiration_intent
* 
* latest_receipt_info
* latest_receipt_info.bid
* latest_receipt_info.bvrs
* latest_receipt_info.app_item_id
* latest_receipt_info.product_id
* latest_receipt_info.item_id
* latest_receipt_info.quantity
* latest_receipt_info.unique_vendor_identifier
* latest_receipt_info.is_in_intro_offer_period
* latest_receipt_info.is_trial_period
* latest_receipt_info.unique_identifier
* latest_receipt_info.transaction_id
* latest_receipt_info.original_transaction_id
* latest_receipt_info.web_order_line_item_id
* latest_receipt_info.version_external_identifier
* latest_receipt_info.expires_date
* latest_receipt_info.expires_date_formatted
* latest_receipt_info.expires_date_formatted_pst
* latest_receipt_info.purchase_date
* latest_receipt_info.purchase_date_ms
* latest_receipt_info.purchase_date_pst
* latest_receipt_info.original_purchase_date
* latest_receipt_info.original_purchase_date_ms
* latest_receipt_info.original_purchase_date_pst
* 
* latest_expired_receipt_info
* latest_expired_receipt_info.bid
* latest_expired_receipt_info.bvrs
* latest_expired_receipt_info.app_item_id
* latest_expired_receipt_info.product_id
* latest_expired_receipt_info.item_id
* latest_expired_receipt_info.quantity
* latest_expired_receipt_info.unique_vendor_identifier
* latest_expired_receipt_info.is_in_intro_offer_period
* latest_expired_receipt_info.is_trial_period
* latest_expired_receipt_info.unique_identifier
* latest_expired_receipt_info.transaction_id
* latest_expired_receipt_info.original_transaction_id
* latest_expired_receipt_info.web_order_line_item_id
* latest_expired_receipt_info.version_external_identifier
* latest_expired_receipt_info.expires_date
* latest_expired_receipt_info.expires_date_formatted
* latest_expired_receipt_info.expires_date_formatted_pst
* latest_expired_receipt_info.purchase_date
* latest_expired_receipt_info.purchase_date_ms
* latest_expired_receipt_info.purchase_date_pst
* latest_expired_receipt_info.original_purchase_date
* latest_expired_receipt_info.original_purchase_date_ms
* latest_expired_receipt_info.original_purchase_date_pst

また、Base64エンコードされたプロパティも見たい場合は以下のヘッダ列も追加してください。

* latest_receipt
* latest_expired_receipt

スプレッドシートは色をつけたり改行ルールを変えたりすると見やすくなると思いますので、その辺りは自由に行ってください。

## 4. URLを iTunes connectに登録

[Appleのサイト](https://help.apple.com/itunes-connect/developer/#/dev0067a330b)に詳しいやり方が出ています。

こんな感じで、今回作成したスプレッドシートのサーバーURLを設定します。

![iTunes-connect-settings.png](https://qiita-image-store.s3.amazonaws.com/0/41616/55ef6551-69e1-6bff-5d55-e6ee47104ccc.png)

なお、このURLは開発環境(Sandbox)と本番環境(Production)で同じものを使用します。どちらの環境の通知が来たかは、`environment`というプロパティで区別できます。実際に受信した結果にも、以下のように environmentプロパティに`Sandbox`と `PROD`の２種類が入っていることが確認できました。

![スクリーンショット 2018-03-07 20.25.32.png](https://qiita-image-store.s3.amazonaws.com/0/41616/1a5f7066-4df8-768c-7dc2-fa74a9142e6c.png)

### 5. Sandboxユーザーで購読をテスト
あとは Appleからの通知が届くのを待つだけなのですが、Sandboxユーザーで実際に購読してみるのが手っ取り早いです。

ただし、**すぐに通知が届かなくても焦らないでください**。どうも、この Appleからの通知は **「即座に」** というほどすぐには届かず、数分のディレイがあるようです。なので、通知が入ってこなくても焦らずにしばらく放置して待っているようにしましょう。

また、後述のように、ユーザーの全ての操作が通知されるわけではないことにもご注意ください。

# 実際に通知を受け取って見てわかったこと

## 通知の種類と通知タイミング

ドキュメントによると、通知には以下の種類があるとあります。

|notification_type|意味|Description|
|---|---|---|
|INITIAL_BUY|初めの購読が行われた|Initial purchase of the subscription. Store the latest_receipt on your server as a token to verify the user’s subscription status at any time, by validating it with the App Store.|
|CANCEL|Appleのカスタマーサポートによる解約|Subscription was canceled by Apple customer support. Check Cancellation Date to know the date and time when the subscription was canceled.|
|RENEWAL|期限切れになった購読が再購読に成功した|Automatic renewal was successful for an expired subscription. Check Subscription Expiration Date to determine the next renewal date and time.|
|INTERACTIVE_RENEWAL|解約していた購読をユーザーが手動で再購読した|Customer renewed a subscription interactively after it lapsed, either by using your app’s interface or on the App Store in account settings. Service is made available immediately.|
|DID_CHANGE_RENEWAL_PREF|次の更新に影響のあるプラン変更が行われた|Customer changed the plan that takes affect at the next subscription renewal. Current active plan is not affected.|

### CANCELはいつ送られる？
試しに、すでに購読中のユーザーを使って iTunesの管理画面で購読を停止してみましたが、一向に CANCEL 通知が送られて来ません。上の表をよく読むと、`CANCEL`はAppleのカスタマーサポートによって特別にキャンセルされたことを通知するもので、ユーザーが手動で購読の自動更新を停止した場合は何も通知されないようです。

Auto-renewable Subscriptionの仕様では、ユーザーが自動更新を停止しても、次の更新タイミングまで権利は剥奪されません。ですので、**ユーザーが iTunesの管理画面で「次の自動更新をしない」という選択をしても、わざわざ通知する必要はないよね**、ということのようです。自動更新を明示的にとめたかどうかを知りたい場合は、こちらのサーバーからAppleのサーバーに対して再度レシート検証を要求して最新のステータスを受信し、今回新たに増えた `Auto-Renew Status` プロパティを調べる必要があるようです。

### RENEWALはいつ送られる？

こちらはまだ試せていませんが、[Appleのフォーラム](https://forums.developer.apple.com/thread/93749)に投稿があり、以下のように解説されていました。

* 正常に購読が更新できた場合、特にRENEWALは通知しない
* 更新時にカード決済の不備などで失敗した場合、Appleはリトライを繰り返すが、そのままexpireしてしまうことがある。その後もリトライを繰り返し、もしカード決済に再度成功した場合は購読が再開され、その際に `RENEWAL`通知を発行する

### INTERACTIVE_RENEWALはいつ送られる？

Sandbox環境で確認しました。

Sandbox環境では、６回くらい更新を繰り返すと自動的に購読が解除される仕組みになっています。Sandbox環境では時間が非常に早く進むようになっていて、1ヶ月更新は 5分ごとに更新が行われます。そのため、30分経つと6回(6ヶ月)の更新が終わり、購読は Expireします。(このExpireでは何も通知されません)

その後(30分経過後)に再度購読を行うと、INTERACTIVE_RENEWAL が発行されます。


以上のような結果となりました。まだ本番環境で１ヶ月以上動かしていないので、どういう通知が受け取れるか調べ切ったわけではないですが、実際にやってみると直感とは違った動作をするのがわかり、大変参考になりました。

皆さんのプロダクトは弊社とは更新期間やプランの設計の違いなどもあると思いますので、また違った現象が起こるかもしれません。ぜひ実際にやってみて挙動を確認することをお勧めいたします。

# 参考情報
- iTunes Connect デベロッパヘルプ 2.8 - 自動更新購読のサーバー通知の有効化
    - https://help.apple.com/itunes-connect/developer/#/dev0067a330b
- Apple Developer Forums - Subscription Status URL, no post object after IAP in sandbox?
    - https://forums.developer.apple.com/thread/83734
- Apple Developer Forums - statusUpdateNotification not getting renewal notification type 
    - https://forums.developer.apple.com/thread/93749
- In-App Purchase Programming Guide
    - https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/StoreKitGuide/Chapters/Subscriptions.html
- WWDC 2017 Session Videos - Advanced StoreKit
    - https://developer.apple.com/videos/play/wwdc2017/305/