Studyplus Engineering Blog

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

Billing Libraryを使ったサブスクリプションのグレード変更をリリースして

こんにちは、モバイルクライアントグループの中島です。 趣味でやってるTCGが、最新のセットで浮世絵サムライニンジャライダーキック合体ロボしながら北斗の拳メタルギアもしつつ、テーマ曲を稲葉浩志と凛として時雨が歌ってて意味がわかりませんでした。 めちゃ楽しかったです。

さて、今回は Google Play Billing Library (以下Billing Library) を用いたアプリで、サブスクリプションのグレード変更をリリースして気づいたことについてお話しします。 機能の実装のために必要なことというより、リリースするために追加で注意が必要だったこと、実際に切り替えが行われた際に「こういうことがあった」などといったtipsを紹介いたします。

Google Play Billing Library のグレード変更について

グレード変更の実装方法、変更する際の比例配分モードなどについて基本的なことは以前のブログにて紹介していますのでそちらをご参照ください。

tech.studyplus.co.jp

tech.studyplus.co.jp

また、昨年度の DroidKaigi 2021 にて、syarihuさんがより詳しく紹介されていますのでそちらも紹介させていただきます。

speakerdeck.com

Studyplus有料版について

まず最初に、Studyplus有料版について、今回のお話で必要な情報をまとめます。

  • プランには「プレミアムプラン」と「ベーシックプラン」の2種類がある
  • 各プランの支払い方法に「月額」と「年額」がある
  • プレミアムプランには7日間の無料試用がある
  • 変更パターンは仕様上、以下の図のように定義している

グレード変更パターン

また、変更パターンごとの比例配分モードは企画チームと相談し、以下のように設定しました。

  • アップグレード時の変更モード
    • IMMEDIATE_AND_CHARGE_FULL_PRICE
  • ダウングレード時の変更モード
    • DEFERRED

本ブログではそれらの運用で気づいたことや内部実装についてお話しします。 「プランとして内容は何が違うのか、できることは何か」などについては、以前のブログをご参照ください。

tech.studyplus.co.jp

info.studyplus.co.jp

話したいこと

リリース前にあったこと

問題編

実装も終わり、挙動テスト/デバッグなども終わったところで問題になったものが Analytics でした。

Studyplus有料版では「課金処理の完了時」に「課金したskuのID」を含めてイベントを送信しています。 「課金処理の完了時」ということで、Studyplus AndroidではPurchasesUpdatedListenerにイベントを仕込みました。 *1

private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
    when (val responseCode = billingResult.responseCode) {
        BillingClient.BillingResponseCode.OK -> {
            // 購入成功したPurchaseを取得
            val purchase = purchases.orEmpty().firstOrNull()
            if (purchase == null) {
                analytics.logEvent(
                    "購入完了",
                    Bundle(2).apply {
                      putString("result", "失敗")
                      putString("plan", "ResponseCode.OKだけどpurchaseがないのでerror")
                    }
                )
            } else {
                // 購入処理
                processPurchases(purchase)

                // 購入成功したsku文字列を取得
                val planString = purchase.skus?.firstOrNull() ?: "empty"
                analytics.logEvent(
                    "購入完了",
                    Bundle(2).apply {
                      putString("result", "成功")
                      putString("plan", planString)
                    }
                )
            }
        }
        BillingClient.BillingResponseCode.USER_CANCELED -> {
            // ユーザーキャンセルのイベントを送信
        }
        else -> {
            // 失敗用のイベントを送信
        }
    }
}

上記のコードはグレード変更がない頃に書いたコードでしたが、特に変更は必要ない認識でした。 しかし、Analyticsの処理を新規仕様から改めて実装している際に問題が発覚しました。

比例配分モード DEFERRED を用いた切り替え時のことです。

private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
  when (val responseCode = billingResult.responseCode) {
    BillingClient.BillingResponseCode.OK -> {
      // 購入成功したPurchaseを取得
      val purchase = purchases.orEmpty().firstOrNull()
      if (purchase == null) { // FIXME: DEFERREDの場合、処理成功時でもpurchaseが入ってこない!!
        analytics.logEvent(
          "購入完了",
          Bundle(2).apply {
            putString("result", "失敗")
            putString("plan", "ResponseCode.OKだけどpurchaseがないのでerror")
          }
        )

DEFERREDでのグレード変更は、とても簡単に言うと「次回の更新日に変更する予約」のような処理になります。 そのためか、ユーザーが切り替え処理を行ったタイミングではPurchaseが返ってきません。

そもそも「ResponseCode.OKだけどpurchaseがないのでerror」という認識が間違っていたのです。

解答編

結論から言いますと、DEFERREDによる変更時はskuのパラメータはemptyとして企画チームと合意を取りました。

どうやって変更先のskuを取得するかを考えてみましたが、「launchBillingFlowの際にskuをvarフィールドで保存しておく」くらいしか思いつかなかったためです。 順当に処理が行われた場合はほぼ問題ありませんが、アプリ内課金は様々な通信が行われる複雑な処理です。

  1. Studyplusアプリ -> PlayStoreアプリ間の通信
  2. PlayStoreアプリ <-> Googleサーバ間の通信
  3. PlayStoreアプリ -> Studyplusアプリ間の通信

Analyticsを送るまでの処理でもこれだけあります。 BillingLibraryの処理を行なうクラスはSingletonで制御しています。 しかしそれでも、処理と処理の間にListenerが挟まっており、直接的に値をやりとりできない状況では信頼性に欠けると判断しました。

最終的な処理は以下になります。

private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
    // NOTE: DEFERREDの時は "empty"
    val planString = purchases.orEmpty()
        .firstOrNull()
        ?.skus
        ?.firstOrNull()
        ?: "empty"

    when (val responseCode = billingResult.responseCode) {
        BillingClient.BillingResponseCode.OK -> {
            // NOTE: DEFERREDの場合は購入が成功した直後にpurchasesがnullで入ってくる、plan名が取れないのでAnalyticsにはemptyが入る
            if (purchases == null) {
                processDeferredPurchase() // NOTE: 通常の購入完了処理ではなく、「次回更新日に変更します」表示をユーザーに提示するのみの処理
            } else {
                processPurchases(purchases)
            }
            analytics.logEvent(
                "購入完了",
                Bundle(2).apply {
                    putString("result", "成功")
                    putString("plan", planString)
                }
            )
        }

リリース後にあったこと

事象の説明

ついにリリースが行われ市場に出ました。 1週間ほど経ち、不具合などもなく安心しかけていたところに企画チームから相談を受けました。

1週間、つまりプレミアムプランを7日間の無料試用で始めたユーザーが、実際に課金されるようになる更新タイミングです。 企画チームが、少し不可思議なサブスクリプション更新日のユーザーを見つけたとのことでした。 日時は仮のものですが、以下のような記録でした。

日付 イベント
2021-12-31 00:00:00 +0900 ベーシックプラン(月)の更新
2022-01-31 00:00:00 +0900 ベーシックプラン(月)の更新
2022-02-01 01:00:00 +0900 プレミアムプラン(年)の購入
2022-03-07 23:00:00 +0900 プレミアムプラン(年)の次回更新

どうやらベーシックプラン(月)の更新後にプレミアムプラン(年)を購入されたユーザーのようですが、どうも年額プランの割に次回更新の日時が中途半端です。

事象の検証

今回の事象の背景ですが、まず前提条件をまとめます。

  • ベーシックプラン(月)→プレミアムプラン(年)はアップグレードと定義しているので、比例配分モードはIMMEDIATE_AND_CHARGE_FULL_PRICE
  • プレミアムプラン(月/年)は初回購入時に7日間の無料試用を提供している

順番に調査、考察をしていきます。

まず公式ドキュメントを参照しに行きます。

developer.android.com

IMMEDIATE_AND_CHARGE_FULL_PRICEは「無料試用中に変更した」場合、以下のように振る舞うと書かれています。

IMMEDIATE_AND_CHARGE_FULL_PRICE ユーザーの Tier 1 定期購入は直ちに Tier 2 にアップグレードされ、ユーザーは無料試用を失います。その日に 2,000 円が課金されます。無料試用期間が 15 日残っているため、次回の請求日は 1 か月に加えてその日から 15 日後、つまり 7 月 1 日になります。7 月 1 日以降は毎月 2,000 円が課金されます。

つまりこの例は「Tier 1 定期購入」を無料試用中の状態から「Tier 2 定期購入」にグレード変更したケースです。 今回Studyplusで観測したケースは「Tier 1 定期購入」状態から「Tier 2 定期購入」に無料試用でグレード変更したケースなので真逆ですね…。

後者のパターンについても書かれていればそれで検証終了だったのですが、書かれていないので引き続き考察します。

日付 イベント
2022-01-01 00:00:00 +0900 ベーシックプラン(月)の更新
2022-01-02 01:00:00 +0900 プレミアムプラン(年)の購入

この部分に注目しますと、ベーシックプラン(月)の更新で600円を精算した直後にプレミアムプラン(年)へ移行していることが見て取れます。 つまり、ユーザーはほぼ600円丸々をチャージしてある状況と言えます。

ここでIMMEDIATE_AND_CHARGE_FULL_PRICEの挙動を考えますと、プレミアムプラン(年)が7900円ですので計算すると以下のようになります。

  • ベーシックプラン(月)を約1日使ったので 600円 / 30日 * 29日分 = 約580円くらい残ってる
  • プレミアムプラン(年)は 7900 / 365 = 1日当たり約22円弱
  • 580円 / 22(円/日) = 約26日

この計算から、約26日延長されることがまず想定できます。

ここで結論を述べておきますが、この約26日無料試用設定の7日を合算した結果が次回の更新日となっています。 *2

日付 イベント
2021-12-31 00:00:00 +0900 ベーシックプラン(月)の更新
2022-01-01 00:00:00 +0900 ベーシックプラン(月)の更新
2022-01-02 01:00:00 +0900 プレミアムプラン(年)の購入(無料試用の開始)
2022-03-07 23:00:00 +0900 プレミアムプラン(年)の次回更新(実際の課金が始まる日)
2023-03-07 23:00:00 +0900 プレミアムプラン(年)の次次回更新

筆者含め、Studyplus内部ではこの約26日が「無料試用が終わった後」に合算されると(勝手に)思っていたため、このユーザーの記録を見て1時間ほど議論になりました。 このパターンも公式ドキュメントに例示して欲しかったなという感想です…。

終わりに

今回は、Google Play Billing Libraryを用いたサブスクリプションのグレード変更をリリースして、気づいたことを軽くまとめてみました。 支払日のズレについては結果的に特に問題にはなりませんでしたが、想定と異なっている点が複数ありました。 プロダクト、時期によっては決算などに影響する場合があるので今回Tipsとして紹介させていただきました。

今年もまたGoogle I/Oが近づいており、個人的にはMaterialDesignなどについて特に発表を楽しみにしています。 一方で、Billing Libraryの更新も近づいているということであり、また準備せねばという思いもあります。 結局Multi-quantity Purchaseなどの続報がありませんでしたし、I/Oでくるのではと想像しています。

最後までご覧いただき、ありがとうございました!

*1:送っている文字列は仮のものです

*2:数字上ズレていますが、おそらく2月が28日しかないのでその辺の計算誤差だろうという認識です