Xamarin.Forms-InAppBillingPluginでの課金実装方法と注意点

xamarinでの課金処理の実装について。
以前書いた記事で、ライブラリ「InAppBillingPlugin」を紹介しましたが、こちらLatestバージョンだととんでもない落とし穴があります。

Xamarin.Forms-InAppBillingPlugin v4について

https://jamesmontemagno.github.io/InAppBillingPlugin/

このライブラリはXamarinでiOS, Androidの両方の課金実装が簡単にできる代物です。v2の記事で紹介した通り、とても簡単です。

そして、Androidの課金APIが更新されたため、この「InAppBillingPlugin」もv4に更新されました。が、注意点と落とし穴が2つありました。

1.公式ページの実装サンプルがv4のものではない

2021年4月現在、公式のページに記載されている実装方法はv2のもので、そのままではビルドがとおりません。なので以下のように修正する必要があります。(サーバーサイドのtokenチェックはなし)

InAppBillingPlugin v4での実装

以下の様なインターフェイスクラスを用意。

    public interface IBillingAccess
    {
        Task<InAppBillingProduct> GetProductInfoAsync(string productId);
      Task<bool> PurchaseItem(string productId, string payload);
      Task<bool> WasItemPurchased(string productId);
    }

そして以下の様なサービスを作成。billing.ConnectAsyncとbilling.GetProductInfoAsyncのinterfaceがv2から変更されています。

public class BillingAccess : IBillingAccess
    {
        public async Task<InAppBillingProduct> GetProductInfoAsync(string productId)
        {
            var productIds = new string[] { productId };
            var billing = CrossInAppBilling.Current;
            try
            {
                var connected = await billing.ConnectAsync();
                if (!connected)
                {
                    return null;
                }
                var items = await billing.GetProductInfoAsync(ItemType.InAppPurchase, productIds);
                InAppBillingProduct ret = items.FirstOrDefault();
                return ret;
            }
            catch (InAppBillingPurchaseException pEx)
            {
                return null;
            }
            catch (Exception ex)
            {
                return null;
            }
            finally
            {
                await billing.DisconnectAsync();
            }
        }

        public async Task<bool> PurchaseItem(string productId, string payload)
        {
            var billing = CrossInAppBilling.Current;
            try
            {
                var connected = await billing.ConnectAsync();
                if (!connected)
                {
                    return false;
                }

                var purchase = await billing.PurchaseAsync(productId, ItemType.InAppPurchase);

                if (purchase == null)
                {
                    return false;
                }
                else if (purchase.State == PurchaseState.Purchased)
                {
                    if (Device.RuntimePlatform == Device.iOS) return true;
           // 非消耗型なら
           await billing.AcknowledgePurchaseAsync(purchase.PurchaseToken);
           return true;
                }
            }
            catch (InAppBillingPurchaseException purchaseEx)
            {
                Console.WriteLine("Error: " + purchaseEx);
            }
            catch (Exception ex)
            {
                Console.WriteLine("Issue connecting: " + ex);
            }
            finally
            {
                await billing.DisconnectAsync();
            }
            return false;
        }

        public async Task<bool> WasItemPurchased(string productId)
        {
            var billing = CrossInAppBilling.Current;
            try
            {
                var connected = await await billing.ConnectAsync();

                if (!connected)
                {
                    return false;
                }

                var purchases = await billing.GetPurchasesAsync(ItemType.InAppPurchase);

                if (purchases?.Any(p => p.ProductId == productId) ?? false)
                {
                    return true;
                }
                else
                {
                    return false;
                }
            }
            catch (InAppBillingPurchaseException purchaseEx)
            {
                Console.WriteLine("Error: " + purchaseEx);
            }
            catch (Exception ex)
            {
                //エラー処理
            }
            finally
            {
                await billing.DisconnectAsync();
            }

            return false;
        }
    }

2.非消耗アイテムの場合はAcknowledgePurchaseAsyncが必要!

1.のようにDocページが古いv2で書かれていたとしても、コンパイルが通らないので、そのことに簡単に気づけますし対応も簡単です。
しかし、非消耗アイテムのアプリ内課金を実装しようとしている場合は、もう一つの落とし穴があります。

1.で書いているコードは正しくAcknowledgePurchaseAsyncをコールしていますが、これをコールしないとユーザーの購入処理後3日でその課金が取り消されてしまいます。
まぁGoogle Play Billing Libraryの最新版では当然の情報らしいのですが、私のようなAndroid開発に全く注力していない人間にとってはさっぱりなわけです。Googleのドキュメントにもしっかりとそのことが書かれています。
https://developer.android.com/google/play/billing/integrate?hl=ja
つまり、2021年8月まではGoogle Play Billing Libraryの最新を利用していなければ、ユーザーの非消耗アイテムの「購入」と同時に「承認」がされていましたが、新しいバージョンでは明示的に「承認」を実行しないと3日後に「購入」がキャンセルされ払い戻しになるということです。
そしてxamarinの「InAppBillingPlugin」でもv4によりこの新しいGoogle Play Billing Libraryに対応したとのことでしたが、この重大な修正点について「InAppBillingPlugin」のページには書かれていないように見えます。(Githubページにはしっかり書かれています)

そのため、お恥ずかしながら購入処理完了後、AcknowledgePurchaseAsyncをコールしていないアプリをストアで公開し、数日間のユーザー様の課金がすべて自動キャンセルされるという事態が発生。
「InAppBillingPlugin」のページだけを鵜呑みにせず、しっかりとGithubも見ていればこのようなことにはならなかったのですが。

「InAppBillingPlugin」を利用される方、私のようなミスを犯さないためにも、
必ずAcknowledgePurchaseAsyncが必要かどうか確認してください。

本当は、Google Developer APIでサーバーサイドから「承認」するのが正しいのかもですが。
https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products/acknowledge