【Flutter】iOS課金処理の、PurchaseStatus.pendingの挙動について

この記事は、[and factory.inc Advent Calendar 2021](https://qiita.com/advent-calendar/2021/andfactory)の **9日目** の記事です。
昨日は、@rd0501さんの「[iOSチームレビュー体制(ルール/目的/観点/よくある質問) まとめ (2021/12)](https://zenn.dev/domonr/articles/b926de7960f2fc)」でした。

# はじめに

Flutterの課金処理を、[公式のプラグイン](https://pub.dev/packages/in_app_purchase)を使って実装している方に向けた記事となります。
iOSのトランザクションステータスは、Flutterの課金ステータス `PurchaseStatus` と若干異なる為、注意するべきポイントを紹介します。
課金タイプは `消耗型` を想定しています。

※最近Ver.2.0.0がリリースされましたが、本記事ではVer.1.0.9をもとにした内容となっております。

# 課金ステータスの種類

## Flutterで用意されている課金ステータス

Flutterで用意されている課金ステータス `PurchaseStatus` はenumで定義されており、以下の5種類があります。
https://github.com/flutter/plugins/blob/master/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/purchase_status.dart

- pending
- purchased
- error
- restored
- canceled

## iOSのトランザクションステータス

iOSのトランザクションステータスは以下の5種類となります。
https://developer.apple.com/documentation/storekit/skpaymenttransactionstate/

- purchasing
- purchased
- failed
- restored
- deferred

# 脳内での変換

なんとなくの直感で、それぞれ5種類あるステータスを脳内で紐付けてみました。
私の中では、以下のように紐付けられたのですが、皆さんはいかがでしょうか？

| ステータス | Flutter | iOS |
| - | - | - |
| 購入完了 | purchased | purchased |
| 保留中 | pending | deferred |
| 復元 | restored | restored |
| エラー | error | failed |
| キャンセル | canceled | failed |

まず、iOSの `purchasing` に相当するステータスがFlutterに用意されていません。
また、エラーとキャンセルの違いもよく分かりません :thinking:
なんとなくiOS標準の課金ダイアログ（AppleID/Passwordを入力するやつ）でキャンセルがタップされた時にここに来そうな気もします。

・・・とは言え、まずは動かして確認してみることに。

```dart
switch (purchaseDetails.status) {
  case PurchaseStatus.purchased:
    {
      // 購入完了時の処理
      // レシート検証などの後続処理を行う
    }
  case PurchaseStatus.pending:
    {
      // 保留時の処理
      // ファミリー共有で親の承認待ちというアラートを表示し、アプリを継続して使用できるようにインジケータの非表示などを行う
    }
  case PurchaseStatus.restored:
    {
      // 復元時の処理
      // 今回は消耗型のプロダクトなので、iOS側では特に処理は行わない
    }
  case PurchaseStatus.error:
    {
      // エラー時の処理
    }
  case PurchaseStatus.canceled:
    {
      // キャンセル時の処理
    }
```

# 実際に動かしてみた

実際に動かしてみると、iOS標準の課金ダイアログが表示されるタイミングで、親の承認待ちを知らせるダイアログが表示されました。

想定外の挙動だったので、プラットフォーム固有の処理が実装されている `in_app_purchase_ios` の中身を見ていくと、 `enum_converters.dart` というファイルがあり、そこで以下のようにステータスが変換されていました。

```dart
  /// Converts an [SKPaymentTransactionStateWrapper] to a [PurchaseStatus].
  PurchaseStatus toPurchaseStatus(SKPaymentTransactionStateWrapper object) {
    switch (object) {
      case SKPaymentTransactionStateWrapper.purchasing:
      case SKPaymentTransactionStateWrapper.deferred:
        return PurchaseStatus.pending;
      case SKPaymentTransactionStateWrapper.purchased:
        return PurchaseStatus.purchased;
      case SKPaymentTransactionStateWrapper.restored:
        return PurchaseStatus.restored;
      case SKPaymentTransactionStateWrapper.failed:
      case SKPaymentTransactionStateWrapper.unspecified:
        return PurchaseStatus.error;
    }
  }
```

ここで注目すべきは、先ほど謎だった `purchasing` のステータスです。
Flutterでは、 `purchasing` も `deferred` も、 `PurchaseStatus.pending` として処理されていたのです。

また、 `failed` のときは一律 `PurchaseStatus.error` が返却されており、 `PurchaseStatus.canceled` は返却されないことも分かりました。
課金ダイアログでキャンセルがタップされたとき、iOS的には `failed` になるので、 `PurchaseStatus.error` が返却されることになります。
（これに関しては、Ver.2.0.0で修正されていました。）

# 修正後

ここまでで、 `PurchaseStatus.pending` には2種類のステータスが乗ってくることが分かりました。
iOS標準の課金ダイアログが表示されるタイミングで `purchasing` が返却されるので、そこでは何も処理をしないようにします。
以下のように修正することで、本来想定していた `deferred` の処理を実現することができるようになりました。

```dart
switch (purchaseDetails.status) {
  case PurchaseStatus.pending:
    {
      if (purchaseDetails is AppStorePurchaseDetails) {
        // iOSの未処理トランザクション一覧を取得し、ProductIdentifierが一致するものを取り出す。
        final paymentWrapper = SKPaymentQueueWrapper();
        final skTransactions = await paymentWrapper.transactions();
        final skTransaction = skTransactions.where((t) => t.payment.productIdentifier == purchaseDetails.productID);
        
        if (skTransaction.isEmpty) {
          break;
        }
        
        if (skTransaction.first.transactionState == SKPaymentTransactionStateWrapper.deferred) {
          // 保留時の処理
          // ファミリー共有で親の承認待ちというアラートを表示し、アプリを継続して使用できるようにインジケータの非表示などを行う
        }
      }
    }

〜〜　pending以外は割愛　〜〜
※未処理トランザクションが複数存在しないことを前提として実装しています。
}
```

# まとめ

同じ `PurchaseStatus.pending` というステータスであっても、iOSでは『ファミリー共有』と『購入ダイアログ表示時』、Androidでは『後払い選択時』（もしくは他にもある？）と、プラットフォームによって全く異なる挙動になるので、細心の注意をはらって実装する必要がありますね。（当然といえば当然ですね。）

今回、Androidに後払いという機能があることを初めて知りました。:sweat_smile:
実装・確認には各プラットフォームに精通したメンバーの力が必要になりそうです。

最終的には、プラットフォーム固有の処理を入れることで、まとめられてしまったステータスを分解することができましたが、力技感は否めないのでもっと良い方法があればぜひコメントいただきたいです。

# おまけ

今回は `PurchaseStatus.pending` について言及する記事だったので掘り下げませんでしたが、in_app_purchaseプラグインの最新版はVer.2.0.0となっており、 `PurchaseStatus.canceled` も返却されるようになりました。

具体的には、iOSの `SKErrorPaymentCancelled` と `SKErrorOverlayCancelled` の時に、 `PurchaseStatus.canceled` が返却されるようです。

Ver.2.0.0からは、プラットフォーム固有の処理が実装されているのは `in_app_purchase_storekit` という名称に変更されており、 `enum_converters.dart` の中身は以下のとおりです。
https://github.com/flutter/plugins/blob/master/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_wrappers/enum_converters.dart

```dart
  /// Converts an [SKPaymentTransactionStateWrapper] to a [PurchaseStatus].
  PurchaseStatus toPurchaseStatus(
      SKPaymentTransactionStateWrapper object, SKError? error) {
    switch (object) {
      case SKPaymentTransactionStateWrapper.purchasing:
      case SKPaymentTransactionStateWrapper.deferred:
        return PurchaseStatus.pending;
      case SKPaymentTransactionStateWrapper.purchased:
        return PurchaseStatus.purchased;
      case SKPaymentTransactionStateWrapper.restored:
        return PurchaseStatus.restored;
      case SKPaymentTransactionStateWrapper.failed:
        // According to the Apple documentation the error code "2" indicates
        // the user cancelled the payment (SKErrorPaymentCancelled) and error
        // code "15" indicates the cancellation of the overlay (SKErrorOverlayCancelled).
        // An overview of all error codes can be found at: https://developer.apple.com/documentation/storekit/skerrorcode?language=objc
        if (error != null && (error.code == 2 || error.code == 15)) {
          return PurchaseStatus.canceled;
        }
        return PurchaseStatus.error;
      case SKPaymentTransactionStateWrapper.unspecified:
        return PurchaseStatus.error;
    }
  }
```

`PurchaseStatus.error` の中で処理を分岐させていた場合は、 `PurchaseStatus.cancel` に移動できそうですね。
本来あるべき姿になったとも思うので、これはありがたい修正ではないでしょうか。