【Flutter】アプリ起動時に未処理トランザクションを処理する方法

この記事は、[and factory.inc Advent Calendar 2021](https://qiita.com/advent-calendar/2021/andfactory)の **14日目** の記事です。
昨日は、@arusu0629さんの「[個人開発アプリに XcodeCloud を導入してみた](https://qiita.com/arusu0629/items/fdd05f36c94662023cec)」でした。

# はじめに

FlutterでiOSの課金処理(消耗型)を実装する際、アプリ起動時に未処理のトランザクションを取得する方法と注意点を紹介します。

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

# ドキュメントの確認

Androidの場合は、未処理のトランザクションを取得する為の `queryPastPurchases` というメソッドが `InAppPurchaseAndroidPlatformAddition ` クラスにあるようです。
https://pub.dev/documentation/in_app_purchase_android/latest/in_app_purchase_android/InAppPurchaseAndroidPlatformAddition/queryPastPurchases.html

iOSでも似たようなメソッドがあることを期待して、 `InAppPurchaseStoreKitPlatformAddition` クラスのドキュメントを確認してみます。
https://pub.dev/documentation/in_app_purchase_storekit/latest/in_app_purchase_storekit/InAppPurchaseStoreKitPlatformAddition-class.html

しかし、いくら探しても未処理のトランザクションを取得するようなメソッドはありませんでした。。。
それどころか、[公式ページ](https://pub.dev/packages/in_app_purchase#restoring-previous-purchases)にトドメをさす一文が書かれていました。

> Note that the App Store does not have any APIs for querying consumable products

iOSは消耗型プロダクトを取得するAPIはないと名言されていました。

---

ただ、iOSネイティブでもアプリ起動時にSKPaymentQueueにobserverをaddして取得していますし、似たようなことをすればいけるのではと思い、いろんなドキュメントを漁ったところ早速見つけました。

課金処理を実装する為には、InAppPurchaseクラスの `purchaseStream` をlistenしておく必要がありますが、 `purchaseStream` の[ドキュメント](https://pub.dev/documentation/in_app_purchase/latest/in_app_purchase/InAppPurchase/purchaseStream.html)に以下のように記載されていました。

```
Listen to this broadcast stream to get real time update for purchases.

This stream will never close as long as the app is active.

Purchase updates can happen in several situations:
- When a purchase is triggered by user in the app.
- When a purchase is triggered by user from the platform-specific store front.
- When a purchase is restored on the device by the user in the app.
- If a purchase is not completed (completePurchase is not called on the purchase
  object) from the last app session. Purchase updates will happen when a new app
  session starts instead.

IMPORTANT! You must subscribe to this stream as soon as your app launches,
preferably before returning your main App Widget in main(). Otherwise you will miss
purchase updated made before this stream is subscribed to.

We also recommend listening to the stream with one subscription at a given time. If you
choose to have multiple subscription at the same time, you should be careful at the fact
that each subscription will receive all the events after they start to listen.
```

イベントのトリガーとして4つ挙げられていますが、ここで注目すべきは4つ目。
意訳すると「未処理のトランザクションがある場合、新しいアプリセッションが開始されたときに更新が行われます。」という感じでしょうか？
「new app session starts」というのは、要はアプリの再起動時かなと解釈しました。

また、重要なこととして、アプリが起動したら、すぐにstreamをsubscribeしろとも書かれています。
これをやっておかないと、更新のイベントを見逃してしまうという、と。

# 実装

あとは公式に記載のあるようにpurchaseStreamを `main()` の中でlistenしてあげれば良さそうです。
ただし、前述のとおりAndroidは未処理トランザクションを取得するAPIがありますので、iOS限定の処理としています。

```dart
  if (Platform.isIOS) {
    late StreamSubscription<List<PurchaseDetails>> _subscription;
    final purchaseUpdated = InAppPurchase.instance.purchaseStream;
    _subscription = purchaseUpdated.listen(
      PurchaseStreamListener.instance.listenToPurchaseUpdated,
      onDone: () {
        _subscription.cancel();
      },
      onError: (error) {
        // エラーハンドリング
      },
    );
  }
```
listenの第一引数に `List<PurchaseDetails>` が渡ってくるので、その中で任意の処理を行えばOKです。
※上記の `PurchaseStreamListener.instance.listenToPurchaseUpdated` の部分。

# 注意点

iOS15.0だと、上記の実装で `List<PurchaseDetails>` が取得できたのですが、iOS15.1.xだと取得できない場合もありました。
これに関しては、原因は分かっていません。
単純にOSバージョンだけが原因なのか、通信環境も悪さをしているのか・・・🤔
もちろん、実装自体が間違えている可能性もありますし・・・🤔

---

こうなった場合、未処理トランザクションを救済する方法としては、以下のような方法が考えられます。
1. `SKPaymentQueueWrapper#transactions` メソッドで、SKPaymentTransactionWrapperのリストを取得する。
2. SKPaymentTransactionWrapperは `transactionState` の[プロパティ](https://pub.dev/documentation/in_app_purchase_storekit/latest/store_kit_wrappers/SKPaymentTransactionWrapper/transactionState.html)を持っているので、ステータスに応じた処理を行う。
※ただし、 `PurchaseDetails` のインスタンスではない為、レシートデータは自分で取得する必要がある。
3. ステータスが `purchased` の場合、レシート検証を行う必要があるので、 `SKReceiptManager.retrieveReceiptData()` メソッドでレシートを取得し、検証を実施する。

最大の注意点として、上記のレシート取得方法は、引数に何も指定できないため何のレシートが返却されるかが全く分かりません。
ドキュメントにも何も記載がありませんでした。
https://pub.dev/documentation/in_app_purchase_storekit/latest/store_kit_wrappers/SKReceiptManager/retrieveReceiptData.html

その為、何かしらの処理を自前で実装し、未処理トランザクションのレシートであることが担保できる場合のみの手段になると考えています。

# まとめ

未処理のトランザクションについては、以下の2段構えで処理を行う必要があることを紹介しました。

1. アプリ起動直後にpurchaseStreamをlistenしてPurchaseDetailsのリストを取得し、課金後の処理を実行する。
2. purcahseStreamからイベントが流れてこない or 受け取れなかった場合は、自分でトランザクションを取得し、課金後の処理を実行する。

ただし、2の処理はレシートを特定することができない為、未処理トランザクションに紐づくレシートであることが担保されている必要があります。
おそらくは1の処理だけで完結できるのが本来あるべき姿なのではないかなと思います。