Edited at

【iOS/Android】アプリ内課金 定期購読のサーバーサイド知識総まとめ


はじめに

ここ数ヶ月の業務で、iOS/Androidそれぞれのアプリ内課金(In-App Purchase)、特に定期購読1(Subscription)について調査し、サーバーサイドの設計や実装をおこないました。その経験をもとに基本事項に始まり、ドキュメントからは読み取れないTips・小ネタまでを今の私に可能な限りまとめてあります。

できるだけ参考情報へのリンクも貼るようにしています。定期購読の実装には必要な知識が数多くあるため、この記事が少しでもその理解の助けとなれば幸いです。

ちなみに、公式ドキュメントにはきちんと目を通した方が良いですが、実態の変更にドキュメントが追いついていない困った部分も多々あります(特に日本語ページ)。この記事でもいくつか触れてはいますが、幅広く情報を集めつつ、最終的には実際に手元で試して情報の正しさを検証することをオススメします。


前提・注意事項


  • この記事は2018年10月ごろまでの情報をもとにしており、その後の仕様変更で内容が古くなってしまっている可能性があります。

  • 十全に調査・検証できていない内容も含まれるため、内容に誤りが含まれる可能性があります。(気づかれた方はご連絡いただけますと大変嬉しく思います。)


この記事の流れ

まず定期購読を扱うサーバーサイドの役割を、ザックリと概要レベルで説明します。

その後、役割ごと・OS別に詳細を説明し、最後にもろもろの留意事項に触れます。


サーバーサイドの役割

定期購読を扱う上でのサーバーサイドの役割は、大きく2つあります。


A. レシートの正しさを検証する

アプリを通してユーザがストアから課金アイテムを購入した時、ストアからその購入の証明書が発行されます(これを以後、レシートと呼びます)。そのレシートが"正しい"ものであれば、アプリでユーザに機能を提供する必要があります。

検証はレシートを受け取ったアプリの中で行うことも可能で、その方法が公式に紹介されてはいます。ただしユーザの手元にあるアプリ内での検証は、リバースエンジニアリングや通信への中間者攻撃等による不正の可能性を排しきれません。そのため、いずれのストアでもバックエンドサーバーでの検証が推奨されています。


It is not possible to build a trusted connection between a user’s device and the App Store directly because you don’t control either end of that connection, and therefore can be susceptible to a man-in-the-middle attack.

--- Receipt Validation Programming Guide - Validating Receipts With the App Store

It's highly recommended to verify purchase details using a secure backend server that you trust. When a server isn’t an option, you can perform less-secure validation within your app.

--- Android Developers - Verify a purchase


各ストアは検証用のAPIを公開しており、それを適切に利用することで安全にレシートの検証が可能です。


B. 購読の自動更新を確認する

定期購読のアイテムは、その定められた期間ごとにストア上で自動更新されていきます。バックエンドサーバーから定期的にストアに問い合わせを行い、ユーザの課金状況を更新する必要があります。2

正しく情報を更新し続けないと、ユーザから見ればストア上では更新が完了しているのにアプリに反映されない、という問題が発生してしまいます。レシート検証と並んで重要な役割です。


A. レシート検証


共通

iOS/Androidいずれも、検証すべき観点は同じです。


  1. 正式なレシートか(ストアの発行したレシートか)

  2. 自身のアプリのレシートか

  3. 未登録のレシートか

  4. 有効期限を迎えていないか

4はレシート検証という観点からは少し外れたものですが、ユーザに機能を提供してよいかの判断、という点で確認は必要です。


iOS


ストアのAPIの用法

まずはストアが公開しているAPIの使い方から確認していきます。

Receipt Validation Programming Guide - Validating Receipts With the App Store

Appleの用意したAPIは本番環境用とサンドボックス環境用の2つで分かれており、それに以下のJSONオブジェクトをボディとしてPOSTリクエストして利用します。

環境
URL

本番
https://buy.itunes.apple.com/verifyReceipt

サンドボックス
https://sandbox.itunes.apple.com/verifyReceipt

{

"receipt-data": "アプリから受信したBase64エンコードされたレシートデータ",
"password": "アプリの共有シークレット", //reciept-dataが、定期購読のレシートを含む場合のみ必須
"exclude-old-transactions": true //trueを指定すると、購読の最新レシート1件だけをレスポンスに含めてくれる
}

審査期間中にAppleがサンドボックス環境のレシートを送信してくるため、「開発時はサンドボックス環境、本番運用時は本番環境用」と設定を切り替えるのではなく、実装としていずれのAPIにもリクエストできるようにしておく必要があります。まず先に本番APIにリクエストし、サンドボックスAPIにリクエストすべきステータスが返却されればそのようにします。

戻り値として以下のJSON形式のレスポンスが得られます。3 4

{

"status": 0, //リクエストの結果を表すコード値
"receipt": {
"bundle_id": "jp.example.app", //アプリのID
"in_app": [
{
"transaction_id": "1111111111111111", //その1回の購入を一意に識別するためのID
"product_id": "jp.example.app.subscription", //購入したアイテムのID
"purchase_date": "2018-12-11 08:08:50 Etc/GMT", //購入日時(※)
"expires_date": "2018-12-11 08:08:55 Etc/GMT" //有効期限
},
]
},
"latest_receipt_info": [
{
"product_id": "",
"transaction_id": "",
"purchase_date": "",
"expires_date": ""
},
],
"latest_receipt": "MII...", //Base64エンコードされたレシートデータ。省略しているが、実際は10KB前後の文字列
"pending_renewal_info": [
{
"auto_renew_status": "1", //今の購読が有効期限を迎えた際、自動更新されるか否かを表す値
"is_in_billing_retry_period": "1" //更新に失敗している購読で、ストアがなお更新を試みているかを表す値
}
]
}

実際の戻り値の全量例(サンドボックス)

秘匿すべき項目は適当な値で代替していますが、実際のレシートデータを見る機会は貴重だと思いますので一例だけでも残しておきます。

{

"status":0,
"environment":"Sandbox",
"receipt":{
"receipt_type":"ProductionSandbox",
"adam_id":0,
"app_item_id":0,
"bundle_id":"jp.example.app",
"application_version":"1",
"download_id":0,
"version_external_identifier":0,
"receipt_creation_date":"2018-12-05 12:05:15 Etc/GMT",
"receipt_creation_date_ms":"1544011515000",
"receipt_creation_date_pst":"2018-12-05 04:05:15 America/Los_Angeles",
"request_date":"2018-12-06 13:50:40 Etc/GMT",
"request_date_ms":"1544104240654",
"request_date_pst":"2018-12-06 05:50:40 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":"jp.example.app.subscription",
"transaction_id":"1000000481802759",
"original_transaction_id":"1000000481802759",
"purchase_date":"2018-12-04 08:03:50 Etc/GMT",
"purchase_date_ms":"1543910630000",
"purchase_date_pst":"2018-12-04 00:03:50 America/Los_Angeles",
"original_purchase_date":"2018-12-04 08:03:51 Etc/GMT",
"original_purchase_date_ms":"1543910631000",
"original_purchase_date_pst":"2018-12-04 00:03:51 America/Los_Angeles",
"expires_date":"2018-12-04 08:08:50 Etc/GMT",
"expires_date_ms":"1543910930000",
"expires_date_pst":"2018-12-04 00:08:50 America/Los_Angeles",
"web_order_line_item_id":"1000000041616744",
"is_trial_period":"true",
"is_in_intro_offer_period":"false"
}
]
},
"latest_receipt_info":[
{
"quantity":"1",
"product_id":"jp.example.app.subscription",
"transaction_id":"1000000481802759",
"original_transaction_id":"1000000481802759",
"purchase_date":"2018-12-04 08:03:50 Etc/GMT",
"purchase_date_ms":"1543910630000",
"purchase_date_pst":"2018-12-04 00:03:50 America/Los_Angeles",
"original_purchase_date":"2018-12-04 08:03:51 Etc/GMT",
"original_purchase_date_ms":"1543910631000",
"original_purchase_date_pst":"2018-12-04 00:03:51 America/Los_Angeles",
"expires_date":"2018-12-04 08:08:50 Etc/GMT",
"expires_date_ms":"1543910930000",
"expires_date_pst":"2018-12-04 00:08:50 America/Los_Angeles",
"web_order_line_item_id":"1000000041616744",
"is_trial_period":"true",
"is_in_intro_offer_period":"false"
},
{
"quantity":"1",
"product_id":"jp.example.app.subscription",
"transaction_id":"1000000481806674",
"original_transaction_id":"1000000481802759",
"purchase_date":"2018-12-04 08:08:50 Etc/GMT",
"purchase_date_ms":"1543910930000",
"purchase_date_pst":"2018-12-04 00:08:50 America/Los_Angeles",
"original_purchase_date":"2018-12-04 08:03:51 Etc/GMT",
"original_purchase_date_ms":"1543910631000",
"original_purchase_date_pst":"2018-12-04 00:03:51 America/Los_Angeles",
"expires_date":"2018-12-04 08:13:50 Etc/GMT",
"expires_date_ms":"1543911230000",
"expires_date_pst":"2018-12-04 00:13:50 America/Los_Angeles",
"web_order_line_item_id":"1000000041616745",
"is_trial_period":"false",
"is_in_intro_offer_period":"false"
}
],
"latest_receipt":"MII...",
"pending_renewal_info":[
{
"expiration_intent":"1",
"auto_renew_product_id":"jp.example.app.subscription",
"original_transaction_id":"1000000481802759",
"is_in_billing_retry_period":"0",
"product_id":"jp.example.app.subscription",
"auto_renew_status":"0"
}
]
}




(※日時値についての補足情報

集合全体に関する情報に、1回1回の購入履歴(これもレシートと呼ばれます)が複数ぶらさがった構造をしています。

各項目の詳細などは、必要に応じて公式を確認してください。

それでは、検証の詳細に移ります。


1. 正当なレシートか

レシートの正当性は、アプリから受け取ったレシートをパラメータとしてAPIにリクエストし、戻り値の項目statusの値で確認します。


説明

0
送信したレシートが有効である。

21007
送信したレシートはサンドボックス環境のものなので、そちらのAPIを呼び直す。


全量は 公式 を参照。ストアAPIの一時的不具合を表すものもあるが、多くは入力値の不正を示す。

0以外の場合は検証失敗です。ステータスコード21007が返された際のみ、同じボディでサンドボックス環境のAPIにリクエストを送れば基本的にはステータス0を返してくれるはずです。

ちなみに送信したレシートに定期購読の購入履歴が含まれる場合、有効なレシートであっても自アプリのものでなければパスワード不正のエラーコードが返されます。


2. 自身のアプリのレシートか

自アプリのレシートかは、戻り値の項目receipt:bundle_idで確認します。この値がアプリのCFBundleIdentifierと一致すればOKです。

各レシートのproduct_idが期待するものかどうかを確認する手法も散見しますが、それはレシート検証の観点ではなく、アイテムの種別によって提供機能が異なる場合に確認するのがよいでしょう。


3. 未登録のレシートか

戻り値に含まれる複数のレシートから最新のもの1つを抽出し、それがサーバーに登録済みか否かを確認します。まずは「複数のレシート」を含む項目がreceipt:in_applatest_receipt_infoの2種類あるため、この違いを説明します。

項目
内容

receipt:in_app
リクエストボディのreceipt-dataを解析したもの。サーバーとアプリが通信した時点で、「そのアプリの中に持っていた購入履歴」のみが含まれる。

latest_receipt_info

receipt-dataが紐づくAppleアカウントから行われた、そのアプリに関するすべての購入履歴が含まれる。ただしこの項目が存在するのは、定期購読アイテムの購入履歴が含まれる場合のみ。(1つでも定期購読の購入履歴がある場合、それ以外のアイテムの購入履歴も含まれる。)

定期購読はストア上で自動更新されるため、アプリが自身で持っている情報とストア上の情報には差分が在り得ます。

また、latest_receipt_infoはリクエスト時のパラメータで要素を最新1件に限定できて簡易に扱えるということもあり、定期購読の検証では基本的にこちらの要素を使用するとよいでしょう。全件を取得した場合は、各要素のpurchase_dateを見て最新のものを抽出します。5

抽出した要素の、項目transaction_idが各購入の識別子です。これと同じ値を持つレシートがDBに登録されていなければ、そのレシートの検証は成功です。検証済みのレシートとしてDBに保存しましょう。


4. 有効期限の確認

最後に保存したレシートの有効期限を確認し、その日時を迎えるまでユーザに課金機能を提供するようにします。有効期限を表す項目はレシートのexpires_dateです(※日時値についての補足情報)。


Android

2019/02/13追記:AndroidのストアAPIが返す情報の変化を、猶予期間を絡めて例とともにまとめました↓

【Androidアプリ内課金】定期購読の猶予期間を1から100まで理解する


ストアのAPIの用法

iOS同様に、先にストアが公開しているAPIの使い方を確認しておきます。

Google Play Developer API - Purchases.subscriptions

GooglePlayのAPIはOAuth 2.0に沿って認可の仕組みを敷いているため、①アクセストークンを取得②取得したトークンを用いて検証のためのAPIにリクエスト、の2ステップで利用します。


①アクセストークンの取得

最初の利用の場合、アプリを公開するGoogleアカウントからリフレッシュトークンを払い出してもらう必要があります(→ リフレッシュトークンについて)。

以下のパラメータで https://accounts.google.com/o/oauth2/token にPOSTリクエストを送信します。

grant_type=refresh_token

client_id=<アカウントのAPIコンソールで生成したクライアントID>
client_secret=<IDに付随するシークレット>
refresh_token=<リフレッシュトークン>

grant_typeは"refresh_token"の固定文字列です。

リクエストに成功すると、JSON形式のレスポンスでアクセストークンとその有効期限が返却されます。

{

"access_token" : "ya29.AHES3ZQ_MbZCwac9TBWIbjW5ilJkXvLTeSl530Na2",
"token_type" : "Bearer",
"expires_in" : 3600,
}


②APIへのリクエスト

次のエンドポイントにGETリクエストを送信します。iOSとは異なり、環境に依らず同じです。

https://www.googleapis.com/androidpublisher/v3/applications/packageName/purchases/subscriptions/subscriptionId/tokens/token

パスパラメータ
説明

packageName
アプリのパッケージ名

subscriptionId
定期購読のアイテム名

token
アイテム購入時に、端末がストアから受け取るトークン文字列

クエリパラメータ
説明

access_token
前ステップで発行したアクセストークン

リクエストに成功すると、以下のJSON形式のレスポンスが得られます。iOS同様に直近の説明で必要な項目以外は省略しているため、取り上げられなかった項目は 公式 で確認してください。

{

"startTimeMillis": 1544515730000, //購読の開始日時(エポックミリ秒)
"expiryTimeMillis": 1544516030000, //購読の有効期限
"autoRenewing": true, //今の購読が有効期限を迎えた際、自動更新されるか否かを表す値
"developerPayload": "free-text", //アプリから購入を行う際に、個別に指定して埋め込める文字列(※)
"orderId": "GPA.1111-1111-1111-11111", //その1回の購入を一意に識別するためのID(※※)
"purchaseType": 0 //サンドボックス環境の時のみ存在し、0を示す
}

(※ developerPayloadについて

(※※ orderIdについて:以前のサンドボックス環境では存在しない・返却されない値だったようですが、返却されるようになっています)

iOSと打って変わってシンプルな構造をしており、対象の定期購読の現在の状況が表現されています。

項目purchaseTypeが存在しなければ本番環境の購読であり、でなければサンドボックス環境のものと判断できます。

それでは、検証の詳細に移ります。


1. 正当なレシートか

アプリはアイテムの購入時、レシートと同時にその署名をストアから受け取っています。サーバーにそのRSA公開鍵を登録しておき、そのセットをSHA-1で検証することでレシートの正当性を担保できます。

Android Developers - Embed your public key for licensing

なお、アプリが購入時に受け取るレシートは以下の内容です。(実際には改行やインデントは含まれません)

{

"orderId":"GPA.1111-1111-1111-11111",
"packageName":"jp.example.app", //アプリを一意に特定できる名前
"productId":"example", //購入したアイテムのID
"purchaseTime":1544515730000,
"purchaseState":0,
"purchaseToken":"xxx...", //ストアのAPIを利用するためのトークン
"autoRenewing":true
}


2. 自身のアプリのレシートか

前ステップで検証したレシートから項目packageName,productId,purchaseTokenを取得し、先述のAPIにリクエストします。そのHTTPステータスコードが 200(OK) であれば、そのレシートは自身のアプリのものです。

3つの項目のセットに対して保持するアクセストークンが適切でない場合、すなわちレシートが自アプリのものではない場合は 400(Bad Request) が返却されます。

アプリのパッケージ名であるpackageNameは不変かつ共通なので、設定値として保持しておいてもよいでしょう。すると他アプリのレシートが送られてきた場合に3つの項目のセット間で不整合が発生し得ますが、その場合もAPIは400を返し、メッセージとしてどこに不整合があるかを教えてくれます。


3. 未登録のレシートか

APIの戻り値の項目orderIdが各購入の識別子です。これと同じ値を持つレシートがDBに登録されていなければ、そのレシートの検証は成功です。

検証済みのレシートとしてDBに保存する際は、アプリから送られてきたレシートとストアのAPIの戻り値を合算して1つのレシートとして保存するようにします。特にproductId,purchaseTokenの値は今後もストアのAPIを利用するのに必須であり、紛失すると何もできなくなってしまいます。


4. 有効期限の確認

有効期限を表す項目はexpiryTimeMillisです。エポックミリ秒が返却されるので、ロケールに気を付けて日時値に変換しましょう。


B. 自動更新の確認

定期購読は、例えば1ヶ月期間のアイテムであれば1ヶ月ごとに、ストアで自動的に新しい購入が処理されてユーザに請求が届き、期限が1ヶ月延伸されます。自動更新はユーザが設定でOFFにすることができますが、それはあくまでも「次回の更新をしない」のであって「OFFにした時点で権利が消失する」というわけではありません。

つまり、有効期限が期間の中途で短縮されることは基本的に想定しなくてよいです。

したがって「自動更新が終わっているだろうタイミングで、再度ストアに問い合わせする」という戦略を基本とすればよいでしょう。ただ、それが遅すぎて有効期限後の確認になってしまうと、ユーザの機能利用に寸断が生じるという問題に繋がります。「有効期限を迎えるよりは前に再度問い合わせする」という戦略も守る必要があるわけですね。

特にGooglePlayからは、自動更新の確認に関連して以下のように注意事項が挙げられています。2


  • 全ての定期購読を毎日照会するようなことはせず、更新が必要な分のみを問い合わせること

  • アプリからのリクエストなどの外的要因で都度問い合わせをしないこと(DBに保存した有効期限などを参照すること)

厳密に自動更新がどのタイミングで行われるのかはストアによって異なるため、詳細をOS別に確認していきましょう。


iOS

Appleストア上での自動更新は、原則的に期限日時の24h前に開始されます。したがって、その間の任意のタイミングで再度ストアのAPIを利用し購読のステータスを更新します。

APIのリクエストパラメータreceipt-dataとして設定する値は、最初にアプリから受け取ったものでも、前回APIの戻り値として受け取ったものでも構いません。この違いで影響を受けるのは項目receipt:in_appだけであり、このステップで参照するlatest_receipt_info及びpending_renewal_infoはいずれも最新の情報が返却されます。(→ pending_renewal_infoの詳細

APIの戻り値から、その購読の状態を以下の3ケースに分類することができます。


ケース1. 自動更新に成功している



  • latest_receipt_infoの中の最新レシートが持つtransactionIdがサーバ未登録の値である

そのレシートをDBに保存し、新しい有効期限までユーザが課金機能を利用できるようにします。

次にAPIに問い合わせるのは、その新しい有効期限の手前です。


ケース2. まだ更新に成功していないが、今後成功する可能性がある



  • pending_renewal_infoのある要素の項目is_in_billing_retry_periodが"1"である

ストア上での自動更新は、様々な理由で失敗することがあります(ユーザの支払い手段が失効している、ストアの一時的不具合など)。一定回数までは失敗しても更新がリトライされる仕組みになっており、仮に有効期限を過ぎていてもこの条件を満たす場合は更新の可能性が残っています。

次にAPIに問い合わせるタイミングとして、まずは現在の有効期限が候補に挙がります。既に有効期限に達している場合は、最も望ましいのは状態更新通知を利用して更新が成功したタイミングを認知することですが、利用が難しい場合は数時間後などということになるでしょう。6


ケース3. その購読は自動更新されない


  • 有効期限を過ぎている

  • 以下のいずれかを満たす



    • pending_renewal_infoの全要素の項目is_in_billing_retry_periodが"0"である

    • 同リストの全要素の項目auto_renew_statusが"0"である



更新が成功しない方向にケース3が進行してストアが更新を諦めたか、ユーザが自動更新をOFFに設定した場合が該当します。

次にストアに問い合わせる機会はありません。ユーザが再度購読を開始する際は、新規購入としてアプリからレシートを受け取り検証します。

また、この時pending_renewal_infoの項目としてexpiration_intentが追加され、その値から購読が更新されなかった理由を把握することが可能です。(→ 値の一覧


Android

Playストア上での自動更新がいつ行われるのかについて、公式に明確な提示はありません。現状として以下の様子であることだけ手元で確認しました。


  • 元々、本来の定期購読の有効期限に対して+N時間された値がexpiryTimeMillisとして返却されている


    • 1ヶ月の定期購読であれば、1ヶ月後からさらに+2時間した値となっている



  • ストア上での自動更新は、ほぼ本来の有効期限ちょうどに実行される


    • ピッタリの実行でも有効期限の切れ目が発生しないように、+N時間がAPIの戻り値としては返却されていると考えられる



そのため、自動更新を反映するための問い合わせはこの短いN時間の中で行う必要がありそうです。7

iOS同様APIの戻り値から、その購読の状態を以下の3ケースに分類することができます。


ケース1. 自動更新に成功している


  • 戻り値の項目orderIdがサーバ未登録である


  • expiryTimeMillisがリクエスト前の有効期限より将来の値である

2019/08/13追記 「支払情報の不承認で継続に失敗している状態で、期限以降に更新をOFFにした」場合にorderIdが新規に発行されてしまうとの事象をご連絡いただいたため、併せて有効期限をチェックする旨を追記しました(= ケース3を優先して判定する)。8

問い合わせのきっかけとなったレシートからproductIdpurchaseTokenなどの値を引き継ぎつつ、そのレシートをDBに保存します。(purchaseTokenは、同じユーザに対する1つの購読の問い合わせでは常に同じ値を使用できます。)

次にAPIに問い合わせるのは、今回と同じく有効期限の+N時間前以降です。


ケース2. まだ更新に成功していないが、今後成功する可能性がある


  • 戻り値で示された有効期限expiryTimeMillisに達していない


  • autoRenewingがtrueである

事情についてはiOSと同様ですが、Playストアでは有効期限に達したあとに更新が成功するケースは無さそうです。また、猶予期間が設定されている場合は特殊な挙動を見せる場合があります(→ 猶予期間について)。

次にAPIに問い合わせるのは、戻り値で示された有効期限とほぼ同等の時間が望ましいでしょう。9


ケース3. その購読は自動更新されない


  • 戻り値で示された有効期限expiryTimeMillisが過去を示す

次にストアに問い合わせる機会はありません。ユーザが再度購読を開始する際は、新規購入としてアプリからレシートを受け取り検証します。


その他Tips・留意事項

レシート検証や自動更新の確認の観点で触れることがなくても、重要な意味を持つもの・ドキュメントを読んでもよく分からないものは数多くあります。(そもそもどこに情報があるのか分からないものも。。)

以下では調査や検証をする中で、私自身が困ったこと、注意したこと等を述べていきます。


iOS/Android共通


本番環境/サンドボックス環境

本番環境は、言うまでもなく実運用する際の環境です。購入の際は実際に課金フローが走り、請求が行われます。

サンドボックス環境は主にテスト時に利用する環境です。購入しても実際の請求は行われません。

本番とサンドボックスにはいくつかの違いがありますが、大きく困ったのが購読の期限が大幅に短縮されることです(例:1ヶ月の定期購読は5分ごとの更新になる)。自動更新を確認するタイミングが難しく、以下のような対応を検討しました。


  • 更新確認用のバッチを5分ごとに動かす

  • 本来の期限に+1日した値を有効期限とし、バッチを日次で動かす

  • バッチではなく、購読1件ごとに更新の確認を制御できる仕組みを構築する

折角のスピーディな動作確認ができるサンドボックス環境の利点を活かしつつ、例外処理やテストのし易さ等々も踏まえて私たちは3点目を採用しました。サンドボックス環境でテストをする以上必ず直面する問題なので、早くから考えておくのが良いと思います。


無料試用期間

高額になりがちな定期購読をユーザが試用体験できるように、両ストアとも無料試用期間の仕組みを用意しています(例:初月無料、その後有料で自動更新)。この時の内部処理ですが、ユーザに請求が発生しないだけで通常の購入と同様にレシートの発行は行われます。通常の購入と同じようにサーバーサイドはそれを通常のフローで検証し、また同様に自動更新の確認をすればよいだけです。

無料期間であることを判別して分析等に役立てたい場合はAPIの戻り値から判別でき、iOSはlatest_receipt_info[]:is_trial_periodが"true"(文字列)であること、AndroidはpaymentStateが2であることが条件となります。

無料試用期間の利用はストア側で制御がされており、同じApple/Googleアカウントで複数回利用することは当然できません。(無料試用→自動更新キャンセル→無料試用→... は不可能です。)


iOS


日時を表す値の種類

レシート検証の節で説明したように purchase_dateexpires_date にはEtc/GMTの日時文字列が入っていますが、公式には記載がないものの他にも2種類の表現で値を返却してくれています。扱いやすいものを活用するとよいでしょう。

項目
説明

xxx_date
協定世界時表現の日時文字列
2018-12-11 08:08:50 Etc/GMT

xxx_date_ms
エポックミリ秒
1544515730000

xxx_date_pst
太平洋標準時表現の日時文字列
2018-12-11 00:08:50 America/Los_Angeles


pending_renewal_info

2017年7月ごろに追加された項目で、定期購読の購入履歴がある場合のみ返却されます。「将来的に更新される、あるいは何らかの理由で過去に更新がされなかった定期購読の情報」がアイテムごと(=product_idごと)に提供されます。

どんな項目がこれの要素であるのか公式に一切記述が見受けられませんが、以下の項目を持つ要素の配列となっています。要素それぞれの詳細については、その多くは公式で確認できます。

{

"product_id": "jp.example.app.subscription", //この要素が提供する対象のアイテム
"expiration_intent": "1", //更新されなかった定期購読のみで返却され、その理由が示される
"auto_renew_product_id": "jp.example.app.subscription2", //次回の更新時に購入されるアイテム
"original_transaction_id": "111111111111111", //購読が開始された初回購入のtransaction_id
"is_in_billing_retry_period": "0", //今の購読が有効期限を迎えた際、自動更新されるか否かを表す値
"auto_renew_status": "1" //更新に失敗している購読で、ストアがなお更新を試みているかを表す値
}

実態として、問い合わせ時点でのユーザの動向として以下のような情報を取得することが可能になっています。


  • 次回の更新をするかどうか

  • 次回の更新で購読のアップグレード/ダウングレードを予定しているかどうか

  • 更新しなかった理由


状態更新通知

定期購読に特定の更新が発生した場合に、登録したエンドポイントに向けてストアからPOSTリクエストを送信してくれるサービスです。使用は必須ではなく、必要に応じて利用すればよいという立て付けになっています。

In-App Purchase Programming Guide - Status Update Notifications

設定方法はこちらに書かれていました。

通知の種別
説明

INITIAL_BUY
定期購読の初回購入がおこなわれた場合(ユーザ単位)

CANCEL
Appleカスタマーサポートによって定期購読がキャンセルされた場合。ユーザ自身が自動更新の設定をOFFにした場合は発生しない

RENEWAL
期限が切れてしまっていた購読の更新に成功した場合。有効期限内に更新が成功した場合は発生しない。(ストアとクレジットカード会社のサーバ間で通信不具合があった場合などで、稀にストア都合で更新に失敗する購読が発生しうる)

INTERACTIVE_RENEWAL
1度期限切れで終了した購読を、ユーザが再度購入した場合

DID_CHANGE_RENEWAL_PREF
定期購読のプランをユーザがアップグレード/ダウングレードした場合

RENEWALとINTERACTIVE_RENEWALはドキュメントでは理解できなかったのですが、このフォーラムの回答が詳細に述べられていて参考になりました。

状態更新通知の活用方法として、特に以下のような点が挙げられます。


  • 期限が切れたが未だ更新が見込める購読に対し、次の更新確認をいつ実行すればいいのかが分かる。


    • 利用しない場合はどうしても更新成功とサーバーからの問い合わせに時差が生じてしまい、その間ユーザは機能を利用できない。



  • プランのグレード変更というユーザ動向を、実施されたタイミングで収集することができる。


    • 利用しない場合は購読の自動更新を確認する時点でしか判別できない。施策の効果を正確に分析したり、ダウングレードの選択があったユーザに通知を送ったりできるようになる。




Android


リフレッシュトークン

Google Play Developer API - Authorization

最初の取得については、リンク先の前半の通りです。 https://accounts.google.com/o/oauth2/token のレスポンスの項目 refresh_token を、アクセストークン取得用のリフレッシュトークンとして長期的に使用していきます。

リフレッシュトークンに明確な有効期限はありませんが、以下のケースで無効となってしまいます。いずれもあまり想定されない事象かとは思いますが、突如有効期限が切れる可能性も考慮しておく必要があります。


  • 払出元のGoogleアカウントにアクセスを取り消された場合

  • 6ヶ月間使用しなかった場合

  • アカウントのパスワードが変更され、かつ認可の対象にGmailが含まれる場合

  • アカウントの払い出している有効なリフレッシュトークンが50を超えた場合


デベロッパーペイロード

先述の通りAndroidのAPIの戻り値 developerPayload には、アプリ⇔ストア間で購入が行われる際に、アプリから任意の文字列を指定して埋め込むことが可能になっています。例えばアプリ内のユーザIDなどを埋め込んでおくことで、レシート検証時にそれがユーザ自身のものであること = 他者に不正利用されていないことを確認する手段として利用されてきました。

ただし現在はこの項目の利用は非推奨とされており、今後の実装ではレシート検証の節の通り orderId を使うべきです。(半分余談ですが、このページは特に日本語ページの遅れが激しいです。)

Androidアプリの実装で使われる一部ライブラリ10では、2017年6月ごろから利用不可な項目となったようです。


猶予期間

概要や設定方法に関してはこちらの「Grace periods & account holds for declined payments」に記載があります。

支払いが滞っているなどの理由で更新に失敗した際に、既定の日数までは有効期限を自動延伸してくれる仕組みです。一気に既定値分が延伸されるのではなく徐々に値が追加されていくので、サーバーサイドもそれに合わせて = 都度値の変わる有効期限に合わせて次の問い合わせを実施し、新しい有効期限でDBの情報を更新していく必要があります。

延伸の合計が既定日数に達するとそれ以上は更新されなくなり、通常の期限切れと同様の扱いができます。


さいごに

まだまだ実運用の経験が足らず、アップグレード/ダウングレードの詳細など知りたいのに記載のない要素もあったかと思います。今後知見が溜まったらまた記事にいたします。

また再度になりますが、もし誤った内容に気づかれましたらご連絡いただけると幸いです。





  1. iOSでは定期購読、Androidでは定期購入とよく呼ばれるようですが、この記事では統一的に定期購読を使います。 



  2. StoreKitを使って実装されたiOSアプリは、毎起動時にストアへ更新を確認して新規購入を処理する必要があります。それを利用して更新ごとにサーバーと通信してもらうという選択肢も採り得ますが、アプリが起動されずとも状態の更新ができる仕組み・機会をサーバー独自に持っておくべきだと思います。 



  3. レスポンスヘッダにContent-Type: application/jsonの指定はありません。 



  4. iOS7以上のレシートです。 



  5. リストの先頭あるいは終端の要素が最新である、という保証はされていません。 



  6. 日次バッチで自動更新の確認をする場合はこれも困難になるので、規約で「支払い手段の失効などで更新が遅れた場合、その反映に時間がかかる可能性があります」と記載して回避する、等の観点も交えて検討が必要になるかと思います。 



  7. 6 と同様日次バッチで対応する方針の場合は不可能なため、サービスとして独自に有効期限を+1日してユーザに提供するなどして回避する必要があるかと思います。 



  8. 現在手元での確認が難しく、自身での確認はできておりません。参考:[Android] 定期購読(Purchases.subscriptions)レシートの例 



  9. AndroidにもiOSの状態更新通知のようなサービスがありますが、GCP必須と都合が合わず未調査/検証です。 -> Add real-time developer notifications 



  10. Google Play Billing Library