📱

UE4のAndroidアプリ内課金は非消費型アイテムに未対応。何もしないと払い戻される問題と対処法

2022/02/16に公開約10,500字

Unreal Engineは2022年2月現在のリリースバージョン、4.27.2(5.0.0-early-access-2も)時点でAndroidアプリ内課金の非消費型アイテムの処理に対応できていません。
これに別途対応せずに非消費型アイテムを実装しリリースすると、Androidのアプリ内課金で購入されたアイテムは3日後に必ず払い戻しが発生します。(iOSは問題ありません。詳しくは後述。)

この記事ではこの問題の原因と対処方法を紹介します。

iOS/Androidにおける消費型と非消費型の違い

ご存知の通り、アプリ内課金には消費することで何度も購入できる「消費型アイテム」と、1回しか購入できず消費という概念がない「非消費型アイテム」の2種類があります。

iOSはApp Store Connectでアイテムを登録する際に消費型(消耗型)、非消費型(非消耗型)を選ぶため登録完了したときにどちらのタイプかが確定しています。そのためiOSの非消費型アイテムは購入するだけで処理が完了し、特別な処理は必要ありません。

消耗型
ゲーム内での進捗を促すライフや宝石、デートAppでの自分のプロフィールの表示頻度を向上するためのブーストなど、様々な種類の消耗型アイテムを提供することができます。消耗型のApp内課金は一度使うとなくなり、再度購入することが可能です。フリーミアムビジネスモデルを使うAppやゲームでよく提供されています。

非消耗型
一度購入すれば無期限に使用できる、非消耗型のプレミアム機能を提供することができます。例えば、写真Appの追加フィルタ、イラスト作成Appの追加ブラシ、ゲームのコスメティックアイテムなどがあります。非消耗型の購入アイテムでは、ファミリー共有を提供することができます。

https://developer.apple.com/jp/in-app-purchase/

一方、Androidはアイテムを登録する際にはどちらのタイプかは決められず、実際にユーザーが購入したあとの処理でどちらのタイプにするかが動的に決まります。ユーザーが商品を購入したあと、消費(Consume)処理をした場合は消費型アイテムになり、承認(Acknowledge)処理をした場合は非消費型アイテムになります。

消費可能アイテムの場合、consumeAsync() メソッドは承認要件を履行し、アプリがユーザーに利用権を付与したことを示します。このメソッドにより、アプリは 1 回限りのアイテムを再購入可能にすることもできます。

消費不可アイテムの購入を承認するには、Google Play Billing Library の BillingClient.acknowledgePurchase() または Google Play Developer API の Product.Purchases.Acknowledge を使用します。購入を承認する前に、アプリは Google Play Billing Library の isAcknowledged() メソッドまたは Google Developers API の acknowledgementState フィールドを使用して、購入がすでに承認されていないかをチェックする必要があります。

https://developer.android.com/google/play/billing/integrate?hl=ja#process

そして現在のUnreal Engineには承認(Acknowledge)処理が実装されていません(!?)。

Unreal EngineでのAndroidアプリ内課金実装の問題点

https://docs.unrealengine.com/4.27/ja/SharingAndReleasing/Mobile/InAppPurchases/

Unreal Engineでのアプリ内課金実装は基本的には上の公式ドキュメントに書かれている通りなのですが、

  • Make In-App Purchase V2ノードを使うと動かない
  • Restore In-App PurchasesノードにドキュメントにはないConsumable Product Flagsという引数がある
  • 消費(Consume)処理をするにはRestore In-App Purchasesノードを使うしかない
  • ここに書かれていないQuery for Owned Purchasesノードがある

など、なかなか癖があるので注意が必要です。Unreal Engineでのアプリ内課金の詳しい実装方法は、改めて別の記事にしようと思います。

そして本題です。前述の通り、Androidで購入されたアイテムを非消費型アイテムにする承認(Acknowledge)処理は、どのノードにも含まれていません。完全に罠です。 何もせずにリリースすると、購入の3日後には自動的に払い戻しされます。(テスト購入の場合は10分ほどで払い戻しされます。)

ドキュメントの購入の完了にはこのような画像があって、Make InAppPurchaseProductRequestノードにIs Consumableといういかにもな引数があるので、偽なら承認処理を内部的にやってくれるのかと思いきやそんなことはありません。むしろこの引数は購入処理内で利用すらされていません(iOSは未確認)。

ちなみに、Restore In-App PurchasesConsumable Product FlagsIs Consumableをtrueにしたオブジェクトを入れると該当のアイテムは消費(Consume)処理されてしまうので非消費型にしたいアイテムに対しては絶対にやってはいけません。

下記が実際のUE4に含まれるAndroidの購入処理まわりのコードです。課金実装をする前に読んでおくと理解が深まります(というか読んでおかないと想定外の動きをするので怖い)。

https://github.com/EpicGames/UnrealEngine/blob/release/Engine/Plugins/Online/Android/OnlineSubsystemGooglePlay/Source/Java/BillingApiV2/com/epicgames/ue4/GooglePlayStoreHelper.java

また、ローカルにUnreal Engineがある場合はProgram Files\Epic Games\UE_4.27\Engine\Plugins\Online\Android\OnlineSubsytemGooglePlay\Source\Java\BillingApiV2\com\EpicGames\ue4\GooglePlayStoreHelper.javaにも同じファイルがあるのでそれを参照してください(Windowsの場合)。ファイルパスはUnreal Engineのインストール先やバージョンによって多少異なります。

Androidで非消費型アイテムの承認処理をする方法

この問題の対処法です。方法には以下の2種類あります。

  • エンジンコードにパッチを当てて承認処理をする(アプリ内で完結させる)
  • Google Play Developer API経由で承認処理をする(バックエンドサービスを作る)

順に説明します。

エンジンコードにパッチを当てて承認処理をする

問題のGooglePlayStoreHelper.javaに承認(Acknowledge)処理を足す方法です。このパッチを当てたUnreal Engineバージョンでビルドすれば、承認処理が走るようになるので比較的楽に導入できます。

承認処理は購入の完了時(Make InAppPurchaseProductRequestノード実行時)と購入の復元時(Restore In-App Purchasesノード実行時)の2箇所に入れます。復元時に入れるのは、Make InAppPurchaseProductRequestノードの処理が終わってもアイテムの購入状態が支払い待ち(Pending Purchase)になる場合があるからです。支払い待ちアイテムが支払い済みになったことはRestore処理の中でわかるので、そのタイミングでも承認処理を行う必要があります。

パッチを当てる該当のファイルはProgram Files\Epic Games\UE_4.27\Engine\Plugins\Online\Android\OnlineSubsytemGooglePlay\Source\Java\BillingApiV2\com\EpicGames\ue4\GooglePlayStoreHelper.javaです(Windowsの場合)。

購入処理後に承認するパッチ

GooglePlayStoreHelper.java
  /**
   * Route taken by the Purchase workflow. We listen for our purchaseIntentIdentifier request code and
   * handle the response accordingly
   */
  public boolean onPurchaseResult(BillingResult billingResult, Purchase purchase)
  {
    ...

      if(purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED)
      {
        final String sku = purchase.getSku();
        final Purchase f_purchase = purchase;
        Handler mainHandler = new Handler(Looper.getMainLooper());
        mainHandler.post(new Runnable()
        {
          @Override
          public void run()
          {
            String receipt = Base64.encode(f_purchase.getOriginalJson().getBytes());

            // START: Patch for acknowledge purchase
            AcknowledgePurchaseParams acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(f_purchase.getPurchaseToken()).build();
            AcknowledgePurchaseResponseListener acknowledgePurchaseResponseListener = new AcknowledgePurchaseResponseListener() {
              @Override
              public void onAcknowledgePurchaseResponse(BillingResult billingResult) {
                //getMessage("Purchase acknowledged");
              }
            };
            mBillingClient.acknowledgePurchase(acknowledgePurchaseParams,acknowledgePurchaseResponseListener);
            // END: Patch for acknowledge purchase

            nativePurchaseComplete(BillingClient.BillingResponseCode.OK, sku, f_purchase.getPurchaseToken(), receipt, f_purchase.getSignature());
          }
        });
      }
    ...
  }

リストア処理で支払い済みを承認するパッチ

GooglePlayStoreHelper.java
  /**
   * Restore previous purchases the user may have made.
   */
  public boolean RestorePurchases(String[] InProductIDs, boolean[] bConsumable)
  {
    ...
              // How do we get purchase here.. need to figure out a way to persist cachedResponse and receipts
              Log.debug("[GooglePlayStoreHelper] - GooglePlayStoreHelper::RestorePurchases - Purchase restored for " + constProduct.getSku());
              String receipt = Base64.encode(constProduct.getOriginalJson().getBytes());
              receipts.add(receipt);

              f_ownedSkus.add(constProduct.getSku());
              f_signatureList.add(constProduct.getSignature());

              // START: Patch for acknowledge purchase
              AcknowledgePurchaseParams acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(ownedProduct.getPurchaseToken()).build();
                AcknowledgePurchaseResponseListener acknowledgePurchaseResponseListener = new AcknowledgePurchaseResponseListener() {
                  @Override
                public void onAcknowledgePurchaseResponse(BillingResult billingResult) {
                  //getMessage("Purchase acknowledged");
                }
              };
                mBillingClient.acknowledgePurchase(acknowledgePurchaseParams,acknowledgePurchaseResponseListener);
              // END: Patch for acknowledge purchase
            }
          }
    ...
  }

コード全体はこちらのGistに置きました。

https://gist.github.com/dforest/5a529b01dbc0d12acda380b61c40825a

このパッチの注意点は消費型アイテムと非消費型アイテムの混在はできない点です。アプリの課金アイテムがすべて非消費型アイテムとして承認されてしまうので、混在させたい場合はパッチを書き直す必要があります。また、エンジンコードへのパッチなので複数のプロジェクトをビルドしている場合は意図せず承認処理が入らないように注意してください。

Google Play Developer API経由で承認する

APIへの接続を想定していないアプリの場合、バックエンドサービスを新しく開発してUnreal EngineにREST API接続まわりの追加しなければならないので導入の難易度は高めです。こちらは検証までできていないので簡単に説明するに留めます。

Google Play Develper APIを利用するための設定は以下を参考に行ってください。

https://developers.google.com/android-publisher?hl=ja

ある購入を承認するにはMethod: purchases.products.acknowledgeに記載のREST APIで行えます。

https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products/acknowledge?hl=ja

Path parametersのtokenはTransaction Identifier、Request bodyのdeveloperPayloadは""(空文字)を指定してやれば大丈夫だったと思います。(うろ覚え……)

パッチせずにリリースしてしまったときの対応

罠に嵌ってこの通知が来ても諦めないでください。Google Play Consoleの注文管理から必要な情報を取得してGoogle Play Developer API経由で承認する方法があります。パッチを当てたリリースが完了するまでの間、この方法で払い戻しを回避することができます。

まずは上記同様にGoogle Play Developer APIを利用する準備をしてください。

https://developers.google.com/android-publisher?hl=ja

Google Play Consoleの「注文管理」メニューでこれまでの注文が一覧できます(アプリ個別のページではなく組織のページにあります)。各注文の中に入ると「購入トークンをコピー」というボタンがあるので、それをクリックしてコピーし、どこかに控えておきます。また、この注文の課金アイテムID(SKU:com.some.thing.inapp1みたいなやつ)も控えておいてください。

下記のMethod: purchases.products.getを使って注文情報の詳細を確認します。packageNameにはアプリのパッケージ名、productIdには先程控えた課金アイテムID、tokenにはコピーした購入トークンをそれぞれ指定します。

https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products/get?hl=ja
{
  "purchaseTimeMillis": "0000000000000",
  "purchaseState": 0,
  "consumptionState": 0,
  "developerPayload": "",
  "orderId": "GPA.XXX-XXXX-XXXX-XXXXX",
  "purchaseType": 0,
  "acknowledgementState": 0,
  "kind": "androidpublisher#productPurchase",
  "regionCode": "JP"
}

すると、こんな感じのレスポンスが返ってきます。このうちpurchaseState = 0acknowledgementState = 0が支払いが終わっているのに承認されてない注文になります。この注文に対してMethod: purchases.products.acknowledgeを実行してあげれば、払い戻されずに済みます。リクエストパラメータは以下の通りです。

  • Path parameters
    • packageName = アプリのパッケージ名
    • productId = 課金アイテムのSKU
    • token = 購入トークン
  • Request body
    • { "developerPayload": "" }

https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products/acknowledge?hl=ja

Method: purchases.products.acknowledgeを実行後にもう一度同じ注文でMethod: purchases.products.getを実行し、acknowledgementState = 1になっていれば成功です。

ただし注文の一覧取得APIは用意されていないので注文管理から手動で課金アイテムIDと購入トークンを集め、ひとつずつ実行していく必要があります。数が多い場合は大変ですので、リリースする前にアプリ側で非消費型アイテムの承認処理に対応して、しっかり検証してからリリースすることをおすすめします。


この問題に対しては2021年10月にForumにFeature Requestが出ていますが、現状返答がありません。新機能というよりAcknowledge処理ができないのは普通にバグだと思うので修正してほしいところです。

https://forums.unrealengine.com/t/feature-request-google-payment-api-fixes-for-acknowledging-a-purchase/254121

参考

https://forums.unrealengine.com/t/android-letting-me-purchase-a-non-consumable-product-more-than-1-time/225684/10
GitHubで編集を提案

Discussion

ログインするとコメントできます