Unity IAP完全攻略への道：消耗型編（+コンビニ決済）

# はじめに
Unityで商用スマホアプリを作成すると避けて通れないのがアプリ内課金。
直近課金を実装することが多く、同じUnityエンジニアのお役に立てればと思い備忘録として残します。

アプリ内課金には消耗型・非消耗型・サブスクがありますが、
本記事では第１弾として、消耗型をUnity IAPでどのように実装するかを説明します。

ご指摘・ご質問お待ちしております。🙏

# 準備
### Unity IAP(In App Purchase)パッケージ導入
メニューバーより、[Window] > [Package Manager]を選択、するとウィンドウが現れます。
左上のドロップダウンを[Packages: Unity Registry]に変更し[In App Purchasing]を選択し右下のインストールを押せば導入完了です。
![スクリーンショット 2024-04-20 17.05.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/323576/d4e7e93e-3e95-b7d2-e2e5-cf7a17fc8058.png)

もしくは、`Packages/manifest.json`に直接書き足す方法でもインストール可能です
```json:manifest.json
{
    "com.unity.purchasing": "4.9.4",
    ...
}
```
:::note info
Unityでアプリ内課金を導入するのに、Unity Gaming Servicesは必須ではありません。

商品データをマスタ等で管理する場合、こちらは使用しない方が管理しやすいので、本記事ではUnity Gaming Servicesを用いない実装について説明します。
:::
### アプリ・アプリ内課金商品登録
実機で課金を確認するために、
* Apple Developer Program/Google Play Consoleへの登録
* Apple Developer Program/Google Play Console上へのアプリ作成
* Apple Developer Program/Google Play Console上へのアプリ内課金の登録

の設定も必要です。ここでは具体的な方法は解説しないので、下記などを参考にしてください。

https://www.youtube.com/watch?v=nXHskB1aJcI

https://qiita.com/Masataka-n/items/6f98a5a9fee7b28ccd1f

# 実装
## 1. 消耗型
まず消耗型課金とは何かというと、ガチャを引くためのジェムなど、使ったら使ったら無くなるアイテムの課金を指します。
まず1.1節で正常に消耗型のアプリ内課金が行われた時のフローについて解説します。

### 1.1 正常系
下記のシーケンス図が正常系（問題なく課金できた時）の消耗型課金の動作フローです。
![Untitled - 消耗型：正常系 (2).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/323576/dae0b4df-3dfd-1eac-46c4-4b935d052636.jpeg)

上から下に沿って課金が行われていて、青枠は課金のトランザクションが張られていることを意味しています。

そして下記が消耗型課金のミニマムな実装例です。
```c#:IAP.cs
using System;
using System.Collections;
using UnityEngine;
using UnityEngine.Purchasing;
using UnityEngine.Purchasing.Extension;
using UnityEngine.Networking;

public class IAP : MonoBehaviour, IDetailedStoreListener
{
    private IStoreController m_StoreController;
    public const string gemProductId = "com.example.myapp.gem";

    private void Start()
    {
        InitializePurchasing();
    }

    // a. 初期化処理
    public void InitializePurchasing()
    {
        var builder = ConfigurationBuilder.Instance(StandardPurchasingModule.Instance());
        builder.AddProduct(gemProductId, ProductType.Consumable);
        UnityPurchasing.Initialize(this, builder);
    }

    // b. 商品一覧
    public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
    {
        Debug.Log("初期化完了");
        m_StoreController = controller;
    }

    // c. 課金要求
    public void BuyGem()
    {
        m_StoreController.InitiatePurchase(gemProductId);
    }

    // d. 購入レシート
    public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args)
    {
        var product = args.purchasedProduct;
        StartCoroutine(SendReceiptToServer(product));

        // ここではレシート未送信なのでPendingを返しトランザクションを張る
        return PurchaseProcessingResult.Pending;
    }

    // e. レシート送信
    private IEnumerator SendReceiptToServer(Product product)
    {
        // サーバーにレシート送信（ダミー）
        var request = UnityWebRequest.Get("https://example.com/receipt/");
        yield return request.SendWebRequest();

        if (request.responseCode < 300)
        {
            Debug.Log($"無事レシートを送れたのでトランザクション完了: {product.definition.id}");
            m_StoreController.ConfirmPendingPurchase(product);
        }

        // 別途ユーザーデータをもとにアイテム反映
    }

    public void OnInitializeFailed(InitializationFailureReason error)
    {
        OnInitializeFailed(error, null);
    }

    public void OnInitializeFailed(InitializationFailureReason error, string message)
    {
        Debug.Log($"初期化失敗: {error}");
    }

    public void OnPurchaseFailed(Product product, PurchaseFailureReason failureReason)
    {
        Debug.Log($"購入失敗 Product:'{product.definition.id}', PurchaseFailureReason:{failureReason}");
    }

    public void OnPurchaseFailed(Product product, PurchaseFailureDescription failureDescription)
    {
        Debug.Log($"購入失敗 Product:'{product.definition.id}', Reason:{failureDescription.reason}, Details:{failureDescription.message}");
    }
}
```
シーケンス図とソースのコメントが対応するように書いていて、
* `InitializePurchasing()`を実行し初期化が完了すると自動で`OnInitialized()`メソッドが呼ばれ
* `BuyGem`を実行し課金が完了すると自動で`ProcessPurchase(args)`が呼ばれ
    * （`OnInitialized`と`ProcessPurchase`はIDetailedStoreListenerのインターフェース）
* その後`SendReceiptToServer`内でレシートをサーバーに送信し、２００番台が返却された（=無事レシートがサーバーに遅れた）ので`m_StoreController.ConfirmPendingPurchase(product)`を呼ぶことでトランザクションを完了

というフローとなっています。ではトランザクションとは何でしょうか？
### 1.1.1 IAPにおけるトランザクションについて
前の説明の通り、課金が完了すると、`ProcessPurchase()`が呼び出されます。
この時の戻り値として、`PurchaseProcessingResult.Complete`を返すとそのまま完了、
`PurchaseProcessingResult.Pending`を返すと未完了状態でトランザクションが張られます。

張られたトランザクションは`m_StoreController.ConfirmPendingPurchase(product)`を呼び出すことで完了にできます。

### 1.2 異常系
なぜこのようなトランザクションを貼る必要があるのでしょうか？

シーケンス図から分かるように、ストアからアプリに購入したことを証明するレシートが送られ、それをサーバーに送信してアイテムを獲得します。
もし仮に、張らずにネットワークエラーや決済完了直後にタスクキルした場合、サーバーにレシートを送ることができません。そうするとアイテムの付与もできなくなり、ユーザーはただお金を払っただけになってしまいます。

これは非常にまずいので、確実にレシートをサーバーに届ける必要があり、その仕組みがトランザクションなのです。

以下にアプリ強制終了したケースのシーケンス図を載せます。
![Untitled - 消耗型：異常系.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/323576/002ee3c0-a153-d928-5ceb-b2bb9cb0782f.jpeg)
d.購入レシートを受け取り、サーバーに送る前にアプリを閉じてしまいました。
しかし`PurchaseProcessingResult.Pending`を返しているのでトランザクションが張られています。

すると次回アプリ起動・初期化完了したタイミングで、再度`ProcessPurchase()`が呼ばれます。
これはトランザクション完了するまで繰り返し行われるので、確実にレシートをサーバーに届けることができます。

### 1.3 コンビニ決済（Google Play Store）
一般的な消耗型課金についての説明は以上ですが、Google Play Storeでリリースする場合、別途コンビニ決済に対応する必要があります。遅延課金(Deferred Purchase)とも呼ばれます。

コンビニ決済のシーケンスは以下のようになります
![Untitled - 消耗型：コンビニ決済.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/323576/0e6e91db-6f23-1a5d-5048-6b6bd89d8931.jpeg)
コンビニ決済の場合、決済開始ボタンを押すと決済コードが発行されるのみで、その場では完了しません。

ユーザーがコンビニに行って支払い後、アプリを立ち上げたタイミングで`ProcessPurchase`が呼ばれるという仕組みになっています。

下記のコードを追加することでコンビニ決済開始を検知できるので、ユーザーに案内を表示したりすると良いでしょう
```diff_c_sharp:IAP.cs
    public void InitializePurchasing()
    {
        var builder = ConfigurationBuilder.Instance(StandardPurchasingModule.Instance());
+       builder.Configure<IGooglePlayConfiguration>().SetDeferredPurchaseListener(
+           product => Debug.Log("コンビニ決済開始")
+       );
    ...
    }
```

また、コンビニ決済開始時に決済未完了のレシートが`ProcessPurchase`に流れることがあるので、省く処理が必要になります。（決済完了後のレシートは別途送られます）
```diff_c_sharp:IAP.cs
+   IGooglePlayStoreExtensions m_GooglePlayStoreExtensions;

    public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
    {
        Debug.Log("初期化完了");
        m_StoreController = controller;
+       m_GooglePlayStoreExtensions = extensions.GetExtension<IGooglePlayStoreExtensions>();
    }

    public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args)
    {
        var product = args.purchasedProduct;

+       if (m_GooglePlayStoreExtensions.IsPurchasedProductDeferred(product))
+       {
+           Debug.Log("コンビニ決済未完了のレシート。サーバーに送る必要がないので無視");
+           return PurchaseProcessingResult.Pending;
+       }
    }
```


## 参考

https://docs.unity3d.com/ja/current/Manual/UnityIAPProcessingPurchases.html