Firebaseで実現するiOSサブスクリプション

6 min read読了の目安(約5900字

個人開発しているアプリ

https://apps.apple.com/jp/app/vocaddict-中上級者の英単語アプリ/id1523868603#?platform=iphone
でサブスクリプションによる有料プランを提供しており、そのバックエンドにはFirebase(Authentication, Firestore, Cloud Functions)を使っています。

運用が難しそうな課金周りのバックエンドも、Firebaseで実現することでサーバー管理など不要で、個人開発でも安定してサービス提供できます。
今回はその大まかな実現方法とともに、個人開発でも意外と容易にサブスクリプション導入できることを紹介します。

尚、この記事は典型的な、有料プラン1種類のみ提供することを前提に書かれています。異なる種類・期間のプランを複数提供する場合は、アップグレード・ダウングレード・クロスグレードを考慮する必要があり、より複雑なものになります。
また、iOS以外の、WebやAndroidなどのプラットフォームで並行してサービスを提供する場合も、課金状態の同期をとったり、複数のプラットフォームで重複して課金されてしまうことを防ぐ仕組みなどが必要になります。

iOSサブスクリプションについて

この記事ではあくまでFirebaseでのサブスクリプションバックエンドに焦点を当てるため、iOSサブスクリプションそのものの詳細やクライアント側の実装にまでは触れませんが、サブスクリプションが初めて、という方はまずこの辺りから派生する記事を一通り読むといいと思います。

https://developer.apple.com/jp/app-store/subscriptions/

サブスクリプション開始までの単純化した流れとしては、

  1. クライアント側の購入処理が成功した際、appStoreReceiptURLを使いレシートを取得
  2. レシートをサーバーに投げる
  3. AppleのverifyReceipt APIでレシートを検証する
  4. Step 3で返されるJSONの各フィールドから、有料プラン適用の要否を検証した上でプラン適用する

と言うイメージになります。

サブスクリプションが自動更新された際の流れも軽く触れておきます。
自動更新されると、Step 1で実装することになるpaymentQueue(_:updatedTransactions:)が新規購入時と同様に呼ばれるので、Step 2以降の処理を行うか、server-to-server notificationを使ってリアルタイムに購読状態の変更通知を受け取り、それをトリガーに通知ペイロードに含まれるJSONを用いてStep 6と同等の処理を行うことになります。
ただ、私のアプリではサーバー通知があったときにSlackに流すようにしているのですが、ごく稀に通知がかなり遅れて(確認できたものでは24時間近く)届くことがあったので、その辺りの可能性は念頭に置いて運用した方が良さそうです。

クライアント側の購入処理

Step 1, 2についても一応、軽く触れます。

準備

何はともあれアプリライフサイクルの初期にSKPaymentQueueadd(_:)を呼び出し、購入・自動更新時のトランザクションを受け取れるようにしておきましょう。

トランザクションは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トリガーを使うことで認証を簡単に行えます。

https://firebase.google.com/docs/functions/callable?hl=ja

レシート検証

まずはverifyReceipt APIでレシートを検証し、レスポンスを受け取ります。
ドキュメントに書いてある通り、Sandboxアカウントでの購入レシートの検証はエンドポイントが異なります。先にProductionのエンドポイントにリクエストし、エラーのステータスが21007だった場合はSandboxにリクエストするようにしましょう。

レスポンスボディはこのようなJSONになっています。

https://developer.apple.com/documentation/appstorereceipts/responsebody

サブスクリプションの管理

今回は、ユーザーの購入履歴を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がいくつかズレる、と言う現象が時々発生します。

https://developer.apple.com/forums/thread/84812

そのため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を使うことでサーバー管理の手間なく、個人でも安定してサブスクリプションを提供できます。
個人開発のマネタイズの幅が広がればいいなと思っています。では!