Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

アプリ内課金の定期購入(サブスクリプション)をFlutterとFirebaseで実装するときのポイント

こんにちは、スタディプラスの須藤(id:kurotyann)です。

昨年の9月にFlutterとFirebaseで新規サービス「ポルト Porto」をリリースしました。

tech.studyplus.co.jp

ポルトはアプリ内課金を未実装でローンチしました1。ローンチ時の決済手段は、Stripeを使ったWebクレジット決済(月額制で無料トライアル14日間)のみです。

高校生をメインターゲットとしながらもアプリ内課金がないのは大きな課題であったため、今年の2月25日にアプリ内課金をリリースしました。

そこで、今回は「FlutterとFirebaseに焦点をあてて」アプリ内課金の定期購入の実装ポイントを紹介します。

1. 技術選定

システム構成図

まず、システム構成図で全体像を示します。

アプリ内課金(定期購入)
f:id:kurotyann:20200413101935j:plain
定期購入のステータス変更通知
f:id:kurotyann:20200413101939j:plain

Flutter(アプリ側)

Flutterにはアプリ内課金の実装をサポートするライブラリがいくつか存在します2。結論から言うと、ポルトではFlutterの公式ライブラリである in_app_purchase を採用しました。

採用理由はFlutterの公式ライブラリであることや、ポルトの料金体系(月額制で無料トライアル14日間)を実装できるライブラリだったからです。ライブラリのおかけでDartのみで実装できました。SwiftやKotlinを書く必要ありません。

このライブラリの使い方は、 packages/in_app_purchase/example を参考にするのが一番の近道です。ポルトでもDartのコードは、 in_app_purchaseのexampleを参考にリファクタリングする程度で済みました。

Firebase(サーバー側)

アプリ側はin_app_purchaseのおかげで楽に完了しました。一方で、サーバー側の実装は大変です。利用したサービスは、Firestore / Cloud Functions / Cloud Storage(GCP)3です。それぞれの役割は次の通りです。

Firestore

  • 定期購入の製品IDの保存
  • ユーザーの課金状態の保存
  • レシート情報の保存

Cloud Functions

  • onCallトリガー

    • 購入 / 復元の処理
  • onRequestトリガー

    • Apple定期購入のステータス変更通知を処理する
    • ユーザーIDでレシートを検証する
  • Pub/Subトピック

    • Google定期購入のステータス変更通知を処理する
  • Pub/Subスケジューラ

    • スケジューラの実行時間の前後n時間以内に有効期限が含まれるレシートを検証する

Cloud Storage(GCP)

  • Pub/Subスケジューラで実行したレシートの検証結果をテキスト形式で保存

2. 実装のポイント

App Store ConnectとGoogle Play Consoleに環境別でアプリを登録

ポルトは本番・ステージング・開発の3環境を準備しており、Firebaseも環境に応じてプロジェクトを分けています。したがって、iOSは PRODUCT_BUNDLE_IDENTIFIERが、Androidは applicationId が環境ごとに異なります。そして、これらのIDはApp Store ConnectやGoogle Play Consoleの登録アプリの情報と同じです。

アプリ内課金の製品は、App Store ConnectやGoogle Play Consoleで事前登録が必要であり、登録しているアプリ情報ごとに製品情報を登録します。つまり、ポルトは環境ごとにIDが異なるため、例えば開発環境のアプリから本番環境の製品情報を取得することはiOSやAndroidでも不可能です。

AppStoreやPlayStoreにリリースするのは本番環境のアプリだけなので、通常はリリースする環境のアプリしか登録しません。これだと開発やステージングでアプリ内課金の動作確認ができないので、App Store ConnectとGoogle Play Consoleに環境別でアプリを用意しました。4

有効期限など確認できるデバッグ画面を用意

アプリの通常利用には必要ないが、開発中に閲覧できると便利なデータを特定の環境や操作で表示できる画面のことを「デバッグ画面」と私は呼んでいます。アプリ内課金の実装時、このデバッグ画面は必須です。

特にiOSのSandbox環境で必要になります。Appleの自動更新の定期購入(auto-renewable subscription)はレシートの自動更新判定が厳しいため、有効期限を確認しながらAppleのレシートを検証することになります。

例えば、開発環境のサブスクリプションを1週間の自動更新にしたとします。このとき、Sandbox環境は3分と短く5なります。そして、自動更新の判定は有効期限の1分前ぐらいから有効になります。購入日や有効期限をアプリのデバッグ画面から確認できないと、どのタイミングでレシート検証APIにリクエストすれば更新されるのかわかりません。

一方、Androidも自動更新判定が必要ですが、iOSに比べると対応はかなり楽です。Androidは、テストユーザーで購入した場合でも本番と同様に定期購入のステータス変更通知(リアルタイム デベロッパー通知)を受け取れます。Cloud Functions for FirebaseのPub/Subトピックを利用して SUBSCRIPTION_RENEWED に合わせて課金データを更新すれば自動更新できます。

特定のユーザーの最新レシートを検証するAPIを用意

Firebase AuthenticationのUIDを渡せば、該当ユーザーの最新レシートを取得して、AppleやGoogleの検証APIへリクエストするようにします。

このAPIは基本的には開発やステージングで使うAPIであり、本番からのリクエストも想定はしますが利用することは、ほとんどありません。後述するPub/Subスケジューラでレシート検証処理をポーリングして、さらに定期購入のステータス変更通知を用意しておけば、アプリ内課金の定期購入は実装できます。

しかし、開発中のデモユーザーのレシートのみ検証したいときや、CSの対応で特定のユーザーのレシートを再検証したい場合など、事前に用意しておくと開発効率が上がります。Cloud Functions for FirebaseのonRequestで実装すれば、ターミナルやPostmanなどのWeb APIクライアントツールでも楽に利用できます。ただし、APIのエンドポイントを外部に漏らさず、漏れたとしても正当なリクエストなのか判定できるような仕組みは入れておくべきです。

collection groupで最新レシートを取得

ユーザーのレシート情報を保存するコレクションIDは、プロジェクト内で一意にしておきます。これで、collection groupを使えばプロジェクト内の全レシートに対して特定の条件をあてながらレシートを抽出できます。

AppleとGoogleのレシート情報は異なるので、 appleReceiptgoogleReceipt などのコレクションIDにして、ユーザーIDのサブコレクションに持たせます。これで特定のユーザーIDで該当ユーザーのレシート情報を抽出できますし、collection groupでプロジェクトの全てのiOSまたはAndroidのレシートを抽出することも可能です。

ポルトの場合は、collection groupで取得したレシートの持ち主を特定しやすくするため、レシートを保存するときレシートの情報にユーザーIDを付与して保存しています。

Pub/Subスケジューラでレシートの有効期限を監視して検証

Appleは最新レシートの有効期限が切れる前の24時間の間に自動更新が有効になり6、Googleは公式ドキュメントに明記されていませんがAppleと同様の範囲で自動更新が有効になっています。Pub/Subスケジューラの頻度はサービスの質によって様々なのでFirebaseの料金と相談しながら、適切な頻度を探ってください。

ポルトでは、毎日3時間ごとに実行時間の前後6時間以内に有効期限が含まれるレシートを抽出して検証しています。そして、定期実行で処理した結果をテキストにして、Cloud Storage(GCP)に保存しています。保存したテキストのURLをslackへ送信することで、ログを閲覧しやすい仕組みにしています。

ステータス変更通知で課金状態を更新

AppleはApp Store Server Notifications、Googleはリアルタイムデベロッパー通知と、定期購入のステータス変更を通知する機能があります。ポルトではAppleはCloud Functions for FirebaseのonRequestで、GoogleはCloud Functions for FirebaseのPub/Subスケジューラで実装しました。

Googleはその名のとおり、本番でもテストでもほぼリアルタイムで定期購入のステータス変更を通知してくれます。通知種別も豊富で自由度が高いです。一方で、Appleは不便です。本番でもテスト(Sandbox)でもリアルタイムで通知されることは稀です。かなりラグがあります。さらに、Googleは自動更新されたことを通知する SUBSCRIPTION_RENEWED がありますが、Appleにはこれと同等のものがありません。必ず自前でレシートの有効期限をポーリング(Pub/Subスケジューラ)して、自動更新を判定しないといけません。

App Store Server Notificationsが役立つと感じたパターンは、支払いエラーで課金が失敗したときや、支払いエラーが解消されたときです。この通知に合わせて支払いエラーの有無を切り替えたり、エラーが起きたユーザーのIDをSlackへ通知しておくと、CS対応が少し捗ります。

3. 終わりに

コードが一切なく、文章ばかりのブログになりました。その理由は、私がアプリ内課金を実装していたとき、欲しかった情報がコードではなく、FlutterとFirebaseでアプリ内課金を実装するときの構成や実装のポイントだったからです。

開発中に調査しましたが、FlutterとFirebaseに関してアプリ内課金の定期購入に触れている記事を見かけませんでした。もちろん、私の検索能力が低い可能性もあるので、良い情報があれば教えて下さい。一方で、AppleやGoogleのアプリ内課金の公式ドキュメントを解説したブログや、どちらか一方の実装でかつアプリ側に寄ったものが多く、サーバー側の視点(レシート検証のポーリングやステータス変更通知 etc)があまりない印象を受けました。

これはアプリとサーバーの担当者が別である場合が多いことや、課金はセキュアな対応のために外へ情報が出づらいなどが理由ではないかと考えています。今回のアプリ内課金の実装は私一人で対応したため、アプリ内課金の全体像を知る良い機会となりました。

FlutterやFirebaseの人気は年々増している印象を受けます。このブログがFlutterとFirebaseでアプリ内課金の実装を検討している人に、少しでも役に立つと嬉しいです。


  1. ローンチ当初、アプリ内課金が未実装なのはビジネス的な話もありますが、Flutterのアプリ内課金の公式ライブラリ(in_app_purchase)が、AndroidのGoogle Play Billing Library 2系をサポートしていなかったことも理由の一つです。in_app_purchaseが2系をサポートしたのは2020年1月7日([In_app_purchases] migrate to Play Billing Library 2.0. #2287)のことでした。

  2. 少し情報は古いですが、https://speakerdeck.com/yasi/present-situation-of-in-app-purchase-in-flutter のスライドが参考になりました。

  3. 課金の分析をするために、Firebase AnalyticsやBigQueryも利用しています。ただ、AnalyticsはUser Propertiesに新しいプロパティを追加したり、Firestoreの課金データをBigQueryへインポートするなど課金以外の分析対応と代わり映えしないので省略しました。

  4. こちらの実装ポイントについては、Twitterで @_monoさんからアドバイスを受けて対応しました。当時のやり取りはこちらのツイートから追えます。

  5. アプリ内課金のテスト時間は、Appleは https://help.apple.com/app-store-connect/#/dev7e89e149d 、Googleはhttps://developer.android.com/google/play/billing/billing_testing#testing-subscriptions です

  6. 公式ドキュメントのDetect an Expiration or Renewalを参照してください。