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


この記事は、and factory.inc Advent Calendar 20219日目 の記事です。
昨日は、@rd0501さんの「iOSチームレビュー体制(ルール/目的/観点/よくある質問) まとめ (2021/12)」でした。

はじめに

Flutterの課金処理を、公式のプラグインを使って実装している方に向けた記事となります。
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に用意されていません。
また、エラーとキャンセルの違いもよく分かりません
なんとなくiOS標準の課金ダイアログ(AppleID/Passwordを入力するやつ)でキャンセルがタップされた時にここに来そうな気もします。

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

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 というファイルがあり、そこで以下のようにステータスが変換されていました。

  /// 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では、 purchasingdeferred も、 PurchaseStatus.pending として処理されていたのです。

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

修正後

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

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の SKErrorPaymentCancelledSKErrorOverlayCancelled の時に、 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

  /// 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 に移動できそうですね。
本来あるべき姿になったとも思うので、これはありがたい修正ではないでしょうか。