Unity IAP 課金実装メモ

# はじめに

## Unity IAPとは

- Unity In App Purchase
- Unity用課金ライブラリ
- 最新バージョンが4.9.4（**2023/08/01**）
    [Changelog | In App Purchasing | 4.9.4](https://docs.unity3d.com/Packages/com.unity.purchasing@4.9/changelog/CHANGELOG.html)
    

## 記事の内容
### やること
- AppStore(iOS) / PlayStore(Android) 両方に対応
- Androidのコンビニ払い対応
- 消費型商品の購入と消費

### やらないこと
- Codeless IAP
- クライアントでのレシート検証（CrossPlatformValidate）
- AppleのAsk-to-buy対応
- 定期購入、非消費購入の対応
- 購入のリストア

# 購入の流れ

1. 準備
2. 初期化
3. 商品の購入
4. レシート検証
5. 購入の確認

## 1. 準備

`IDetailedStoreListener`を実装したクラスを用意する。

以下では`DetailedStoreListenerImpl`とした：

```csharp
public class DetailedStoreListenerImpl : IDetailedStoreListener
```

`IDetailedStoreListener`は`IStoreListener`の拡張で、以下のメソッドが追加されている：

```csharp
public interface IDetailedStoreListener : IStoreListener
{
    void OnPurchaseFailed(Product product, PurchaseFailureDescription failureDescription);
}
```

`IStoreListener`の`OnPurchaseFailed`は既に`Obsolete`なので、前述のものを利用する。

```csharp
public interface IStoreListener
{
    [Obsolete("Use IDetailedStoreListener.OnPurchaseFailed for more detailed callback.", false)]
    void OnPurchaseFailed(Product product, PurchaseFailureReason failureReason);
}
```

初期化成功時に`IStoreController`と`IExtensionProvider`が返ってくるので、`DetailedStoreListenerImpl` で保存しておく。

```csharp
void OnInitialized(IStoreController controller, IExtensionProvider extensions);
```

```csharp
public IStoreController StoreController { get; private set; } = null;
public IExtensionProvider ExtensionProvider { get; private set; } = null;
public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
{
    StoreController = controller;
    ExtensionProvider = extensions;
}
```

## 2. 初期化

UnityIAPを使用するために必要な初期化は2段階ある：

1． Unity Gaming Serviceの初期化
```csharp
// Unity Gaming Serviceの初期化
await UnityServices.InitializeAsync();
```

2． Unity InAppPurchaseの初期化

```csharp
// StoreListenerの作成
var storeListener = new DetailedStoreListenerImpl();            
// Buidlerの作成
var module = StandardPurchasingModule.Instance();
var builder = ConfigurationBuilder.Instance(module);
var googleConfig = builder.Configure<IGooglePlayConfiguration>();
googleConfig.SetDeferredPurchaseListener(product => storeListener.OnPurchaseDeferred(product));
// 商品の登録
foreach (var id in productIds) builder.AddProduct(id, ProductType.Consumable);
// IAPの初期化
UnityPurchasing.Initialize(storeListener, builder);
```

StoreListenerは、前述した`IDetailedStoreListener`を実装したクラスのインスタンス（＝`DetailedStoreListenerImpl`）が必要。

Builderには、遅延購入時に`StoreListener`の`OnPurchaseDeferred`を発火させるよう設定してをセット。

その後商品情報を登録。消費型のため`ProductType`は`Consumable`とする。

そして`StoreListener`/`Builder`の両方を使ってIAPを初期化ができる。

成功すると`OnInitialized`コールバックが返るので、`IStoreController`と`IExtensionProvider`を保存しておく。

```csharp
public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
{
    this.StoreController = controller;
    this.ExtensionProvider = extensions;
}
```

## 3. 商品の購入

初期化時に得た`IStoreController`に対し、`Product`を渡して商品を購入する。

```csharp
this.StoreController.InitiatePurchase(product);
```

これを実行すると端末のネイティブ課金UIが出る。

成功すると、`IDetailedStoreListener.ProcessPurchase`がコールバックとして呼ばれる。

この後レシートを検証する必要があるため、`ProcessPurchase`内では一旦`PurchaseProcessingResult.Pending`を返す。

```csharp
public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs purchaseEvent)
{
    // ~中略~

    return PurchaseProcessingResult.Pending;
}
```

## 4. レシート検証

レシートの内容をAPサーバに送り、そこから各ストアに送信して検証する。

当記事では詳説しない。

### Appleの場合
`UntiyEngine.Purchasing.Product`の`receipt`が`string`だからといって直接AppStoreに送らない。
実際にAppStoreが求めている`receipt-data`は`receipt`の中の`Payload`部分のみなので、jsonを分解して渡す。（1ミス）


```cs 
    var r = JsonUtility.FromJson<AppleReceipt>(Product.receipt);
```

```cs
    [Serializable]
    public class AppleReceipt
    {
        public string Payload;
        public string Store;
        public string TransactionID;
    }
```


> receipt-data
> (Required) The Base64-encoded receipt data.

https://developer.apple.com/documentation/appstorereceipts/requestbody

ちなみに`verifyReceipt`エンドポイントはもう`[Deprecated]`なので、APサーバもリプレイス頑張ろう。

## 5. 購入の確認

レシートの妥当性が検証されたら、購入を確認（Confirm）し、消耗品を消費する。

```csharp
this.StoreController.ConfirmPendingPurchase(product);
```

# その他処理

## 購入のキャンセル

購入のネイティブUIをユーザがキャンセルすると、`PurchaseFailureReason.UserCancelled`を持った`PurchaseFailureDescription`を引数として、`IDetailedStoreListener`の`OnPurchaseFailed`が呼び出される。

```csharp
public void OnPurchaseFailed(Product product, PurchaseFailureDescription failureDescription)
{
    if (failureDescription.reason == PurchaseFailureReason.UserCancelled)
    {
         // UserCancelled
    }
}
```

## 購入の失敗

購入が失敗すると、`PurchaseFailureReason.PaymentDeclined`を持った`PurchaseFailureDescription`を引数として、`IDetailedStoreListener`の`OnPurchaseFailed`が呼び出される。

```csharp
public void OnPurchaseFailed(Product product, PurchaseFailureDescription failureDescription)
{
    if (failureDescription.reason == PurchaseFailureReason.PaymentDeclined)
    {
         // PaymentDeclined
    }
}
```

## コンビニ払い

Androidのコンビニ払いに対応する。

### 遅延された購入の通知

初期化で示した例では、以下のように遅延購入のコールバックを登録したが、ユーザーに通知する必要がなければ特に行わなくてもよい。

```csharp
googleConfig.SetDeferredPurchaseListener(product => storeListener.OnPurchaseDeferred(product));
```

`OnPurchaseDeferred`は`Product`を引数に取るが、この`Product`の`hasReceipt`は`false`である(※1)。ダイアログなどでユーザーにコンビニ払いを促す。

```csharp
public void OnPurchaseDeferred(Product product)
{
    Dialog.Show("コンビニ支払いしてください。");
    // Debug.Log($"コンビニ払いを選択しました: {product.definition.id}");
}
```

※1 GPBLの`PurchaseState`には`Pending`があるので`Pending`でレシートを付けてくれると嬉しいのだが…

https://developer.android.com/reference/com/android/billingclient/api/Purchase.PurchaseState

※2 上記ドキュメントに`Pending`は`Constant Value: 2 (0x00000002)`と記載があるが、実際に`Pending`状態の`Product`のレシートを参照すると`PurchaseState`は`4`になっている。

https://forum.unity.com/threads/purchasestate-4-out-of-three-possible.1161470/

### 遅延購入の購入処理

コンビニ払いが行われたならば、**よきタイミング**で`ProcessPurchase`が発火し、通常購入と同じように購入処理がなされる。基本的に予見できないので、いつ`ProcessPurchase`が発火しても良いように設計しておく。

大きくパターン分けをすると「再起動した場合」「起動したままの場合」に分けられると思われるが、いずれの場合も`ProcessPurchase`をトリガーに検証＆付与のフローが適切に実装されていれば、あまり問題になることはない。ただ購入フローの最後に「◯◯を付与しました」のようなダイアログを出すようになっている場合は、いつ出るかタイミングが掴めない以上、少し検討が必要かもしれない。

### コンビニ払いのキャンセル

コンビニ支払いを選択した後にPlayストアで手動でキャンセルしたり、コンビニ払いを期限までに行わなかったなどの理由で自動でキャンセルした場合、キャンセルされた購入の`ProcessPurchase`は呼ばれることはない。`Pending`状態のレシートをサーバに送ったりしている場合、サーバ側では期限切れを判断して`Pending`レシートを消去するなどの処理が必要である。

### コンビニ払いの未消費

コンビニ払いをしたのに、その後アプリを**72時間起動せず**、`ProcessPurchase`で消費されなかった場合、キャンセル扱いになる。この場合のキャンセルは**Playストアの残高**に充当される。