Firebaseで実現するiOSサブスクリプション
個人開発しているアプリ
でサブスクリプションによる有料プランを提供しており、そのバックエンドにはFirebase(Authentication, Firestore, Cloud Functions)を使っています。運用が難しそうな課金周りのバックエンドも、Firebaseで実現することでサーバー管理など不要で、個人開発でも安定してサービス提供できます。
今回はその大まかな実現方法とともに、個人開発でも意外と容易にサブスクリプション導入できることを紹介します。
尚、この記事は典型的な、有料プラン1種類のみ提供することを前提に書かれています。異なる種類・期間のプランを複数提供する場合は、アップグレード・ダウングレード・クロスグレードを考慮する必要があり、より複雑なものになります。
また、iOS以外の、WebやAndroidなどのプラットフォームで並行してサービスを提供する場合も、課金状態の同期をとったり、複数のプラットフォームで重複して課金されてしまうことを防ぐ仕組みなどが必要になります。
iOSサブスクリプションについて
この記事ではあくまでFirebaseでのサブスクリプションバックエンドに焦点を当てるため、iOSサブスクリプションそのものの詳細やクライアント側の実装にまでは触れませんが、サブスクリプションが初めて、という方はまずこの辺りから派生する記事を一通り読むといいと思います。
サブスクリプション開始までの単純化した流れとしては、
- クライアント側の購入処理が成功した際、appStoreReceiptURLを使いレシートを取得
- レシートをサーバーに投げる
- AppleのverifyReceipt APIでレシートを検証する
- Step 3で返されるJSONの各フィールドから、有料プラン適用の要否を検証した上でプラン適用する
と言うイメージになります。
サブスクリプションが自動更新された際の流れも軽く触れておきます。
自動更新されると、Step 1で実装することになるpaymentQueue(_:updatedTransactions:)
が新規購入時と同様に呼ばれるので、Step 2以降の処理を行うか、server-to-server notificationを使ってリアルタイムに購読状態の変更通知を受け取り、それをトリガーに通知ペイロードに含まれるJSONを用いてStep 6と同等の処理を行うことになります。
ただ、私のアプリではサーバー通知があったときにSlackに流すようにしているのですが、ごく稀に通知がかなり遅れて(確認できたものでは24時間近く)届くことがあったので、その辺りの可能性は念頭に置いて運用した方が良さそうです。
クライアント側の購入処理
Step 1, 2についても一応、軽く触れます。
準備
何はともあれアプリライフサイクルの初期にSKPaymentQueue
のadd(_:)
を呼び出し、購入・自動更新時のトランザクションを受け取れるようにしておきましょう。
トランザクションはfinishTransaction(_:)
で完了させるまでは保留の扱いになります。
保留中のtransactions
もこのタイミングで処理しておきましょう。
またSKProductsRequestで課金アイテムを取得しておきましょう。
購入時
購入ボタンがタップされら、
- 保留中のトランザクションが残っていないこと
- そのデバイスでアプリ内課金が許可されていること
を確認し、SKPaymentまたはSKMutablePaymentを使い、実際の購入処理を実行します。
これを実行すると、iOSがユーザーに対しダイアログを提示します。購入されるか、エラーになるか、キャンセルされるとSKPaymentTransactionObserver
のメソッドが呼ばれるので、購入に成功していたらサーバーにレシートを送ります。
今回はCloud Functionsへのリクエストになるので、こんなイメージです。
Functions.functions(region: "asia-northeast1").httpsCallable("iosSubscription").call(["receipt": receipt]) {
// 結果のハンドリング
}
サーバー側の有料プラン適用処理
では、ここからサーバー側の処理になります(Step 3~4)。
一応その前に、アプリ内課金を行うアプリにおいてユーザー認証はアプリの要件によっては必ずしも必要ではないステップですが、Cloud Functionsを使う場合、functions.https.onCall
トリガーを使うことで認証を簡単に行えます。
レシート検証
まずはverifyReceipt APIでレシートを検証し、レスポンスを受け取ります。
ドキュメントに書いてある通り、Sandboxアカウントでの購入レシートの検証はエンドポイントが異なります。先にProductionのエンドポイントにリクエストし、エラーのステータスが21007
だった場合はSandboxにリクエストするようにしましょう。
レスポンスボディはこのようなJSONになっています。
サブスクリプションの管理
今回は、ユーザーの購入履歴をFirestoreに保存し、有料プラン利用中かどうかの判断にはこの履歴を利用します。
-
users
がユーザーのコレクション - 個々のユーザーを表すドキュメントが
purchases
と言うコレクションを持つ -
purchases
がこんな形のドキュメントを保存すれば最低限はOKだと思います。
key: web_order_line_item_id
fields:
- purchase_date
- original_transaction_id
- expires_date
- cancellation_date
必要に応じてフィールドは取捨選択してください。
注意点
keyがtransaction_id
ではなくweb_order_line_item_id
になっていることが気になった方がいるかもしれませんが、これはtransaction_id
は変わる可能性があるためです。ドキュメントには
A unique identifier for a transaction such as a purchase, restore, or renewal.
とあり、同じ購入を示していても少なくともリストア時には変わることは示されていますが、それ以外にも課金アイテム購入直後のverifyReceipt
と、少し時間を空けて呼び出すverifyReceipt
で、transaction_id
がいくつかズレる、と言う現象が時々発生します。
そのためverifyReceipt
した結果から、アプリケーションデータベース上の一致するトランザクションや、トランザクションに紐づくユーザーを特定することなどにtransaction_id
を使うとうまくいかないことが出てくるので、web_order_line_item_id
を使っています。
有料プランの適用
web_order_line_item_id
をkeyに、すでにFirestore上に同一トランザクションが存在しないか確認し、存在する場合は何もせず成功レスポンスを返します。
// ユーザー横断して検索するためcollectionGroupを使う
const snapshot = await firestore.collectionGroup('purchases').where('web_order_line_item_id', '==', txn['web_order_line_item_id']).get();
// 適用済みトランザクション
const isDuplicated = !snapshot.empty
または、そのトランザクションがキャンセルされていた場合は、cancellation_date
に値を入れてドキュメントを更新します。
念の為トランザクションのproduct_id
が、提供しているアプリ内課金アイテムのIDと一致することを検証し、問題なければpurchases
コレクションにドキュメントを追加します。
一旦完成
これで、アプリ側ではFirestoreから、ログイン中のユーザーに紐づくexpires_date
が最も新しいトランザクションを取得し、
-
cancellation_date
に値が設定されていない -
expires_date
が現在日時より後である
ことを条件に有料プラン利用ユーザーであると見なすことができます。
自動更新時は、前述の通り、クライアントでトランザクション更新をハンドリングしてこれまでの処理を繰り返すか、server-to-server notificationを利用します。
サブスクリプションは簡単?
最初に書いた通り、複数種類の課金アイテムを提供したり、複数プラットフォームでサービス展開する場合かなり複雑にはなりますし、課金アイテムの価格改定を行う場合にも、若干独特な運用が求められたりします。またiOSのアプリ内課金周りは、ドキュメントが曖昧だったり、ドキュメントと実際の挙動が違うことが時々あり、簡単とは言えません(仕事で苦い汁飲みまくってきたのでw)。
そんなこんなで個人では難しそうなイメージがある課金やサブスクリプションですが、ちょっとしたiOSアプリを開発してサブスクリプションを実装するくらいなら、今回紹介した程度で実現できますし、Firebaseを使うことでサーバー管理の手間なく、個人でも安定してサブスクリプションを提供できます。
個人開発のマネタイズの幅が広がればいいなと思っています。では!
Discussion