【Flutter】もう怖くない！アプリ内課金・定期購入機能を実装する方法を丁寧に説明してみた。

![トップ2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228439/7356a55f-67ae-8929-5042-ca79983f4a38.jpeg)


7月にFlutter開発を始めてから２作目、アイデアを発想するためのメモアプリ「アイデアメモ iX」をリリースしました。
走り書きをする感覚でサッとメモができ、さらにそのメモを組み合わせてシャッフルして表示したり、ランダムで過去のメモをピックアップしたり、アイデアのヒントになるようなワードを表示したり、アプリのアイデア出しにぴったりなアプリです。

<iframe width="560" height="315" src="https://www.youtube.com/embed/gS46Uf-Qndo" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

■AppStore
https://apps.apple.com/jp/app/id1517535550

■Google Play
https://play.google.com/store/apps/details?id=com.IdeaShuffleMemoApp&hl=ja

■アプリの詳細記事
https://yukio.site/idea_shuffle_memo/

さて、このアプリを作成するにあたり、３つの機能を実装しようと取り組みました。この記事では、その中で定期購入・購読する方法を書いていきたいと思います。

■パスワードロック機能実装ついてはこちら
https://qiita.com/YuKiO-OO/items/bf2d1d107d1a66211619

■バックアップ機能実装についてはこちら
https://qiita.com/YuKiO-OO/items/67b471e6be6c4c4c26e9

アプリを作るなら、どうせなら**稼げるアプリ**を作りたいですよね。

ただ、有料課金、特に**定期購入・購読**はハードルが高いな〜と尻込みしていませんか？

今回は、できるかぎり噛み砕いて、定期購入・購買機能を実装した方法を紹介していきたいと思います。


### 実現したいアプリ内課金機能
iOSとアンドロイドユーザーに対して、月額、年額の自動更新されるアプリ内課金・定期購入・購読（サプスクリプション 以降サブスク）に提供することを目的にします。


### 提供するサービス形態
サブスク機能実装の前に考えてないといけないのが、有料で提供するサービスの形態です。
今回は、アプリの拡張機能をサブスクで提供することを前提にします。

例えばWebサービスでも提供しているような、アプリではなくコンテンツで提供しているサービス（例えばネットフリックス、スタディサプリ）の場合は今回の方法では対応ができません。

アプリの有料課金について調べていると、コンテンツ提供のパターンでの有料課金を説明している場合が多いです。
コンテンツ提供パターンの場合、課金状態をどのツールからでも確認する必要があるので、外部に**購入履歴（レシート）**を保存して、ユーザーと紐づけ。
課金状態の変化を常にチェックする必要もあったりと、かなり複雑な構成になります。　→ だからよく分からなくなる。

今回はアプリの拡張機能をサブスクで提供するパターンので、外部サーバーとの連携は購入履歴のチェックのみとシンプルな構成になります。

### 購入履歴（レシート）とサーバーの連携
アプリの有料課金について調べていると「レシートの確認、レシートの正当性」のようにレシートという言葉が頻繁に登場します。

「白くて細長い紙」のイメージが頭に浮かびますが、実際に紙が発行されるわけではありません。
レシートとは、購入履歴のデータの事です。（レシートと書いて説明すると分かりづらいので、これから先は購入履歴で統一します）

消費型でもサブスク型でも、基本は一緒で、各アプリストアにそのユーザーの購入履歴を問い合わせて、それが有効と確認できたら。対応するサービスを提供する流れになります。

ただ、ここで一つ問題があります。

アプリ内で購入履歴のチェックすることもできるのですが、仮にアプリ内で購入履歴を偽造されてしまった場合・・・

不正に有償サービスが使用されることになります。

それは困るので、いったん購入履歴を外部サーバー（ここでFirebaseが登場）に送ります。
外部サーバーからストアに購入履歴を問い合わせて、正当な購入履歴だと判断とされたら「この購入履歴はOKです！」と返してもらう処理をします。

この一連の処理は、ストア側で用意されておらず、こちらで用意する必要があります。

### サブスク更新のチェック
サブスクを実装する上でもう一つ考えるべきことが、「サブスク更新のチェック」です。

サブスク更新していないユーザーに、有料機能を提供し続けると損することになります。

そのため、サブスクが有効期限切れの場合には、有料機能をOFFにする処理も必要です。

今回リリースしたアプリでは、サブスクの説明ページを開いたタイミングと、アプリ内で一定の条件が揃った場合にサブスク更新のチェックを走らせるようにしました。（セキュリティーの観点から条件は伏せておきます）

本来であればリアルタイムでの検知が望ましいですが、アプリ単独でサービスが完結するのでリアルタイムの重要性が低いですし、少し長めに使えてしまっても、サービスでいいかなと思いました。

だから、何回もストアに問い合わせするのも重くなるので、リアルタイムではチェックさせていません。

リアルタイムで課金状態を反映したい場合は、サーバーで処理をしたり、ストアから更新があった場合に通知するように設定する必要があります。


### 全体の流れ
#### 事前準備
- 各ストアにサブスクアイテムの作成・登録
- 購入履歴のチェック用サーバーの設定

#### アプリ内の処理
- 各ストアにユーザーがログイン
- サブスクアイテムのデータを読みこんで表示（自動でローカルの通貨に変更になる）
- ユーザーが購入処理。（ボタンをおす）
- ストアから最新の購入履歴を取得
- 購入履歴をサーバーに飛ばす
- サーバーで購入履歴をチェック
- OKだったら、有料機能を提供
- 時々、課金状態のチェック

### 注意事項
#### サブスク機能はアプリが完成してから実装するのがおすすめ
後ほど説明しますが、サブスク機能の実装は、ほぼアプリが完成した最後のタイミングで実装した方がいいと思います。

特にiOSの方が面倒だからです。

iOSの課金テストでは、Sandboxユーザーというテストユーザーを使います。

1ヶ月が５分、年間が30分と短くなるのですが、定期購入が５回までしか自動更新されず、それ以降課金テストができなくなります。（私の場合なのかもしれませんが・・・）
他にエラーが残っている状態でテストしたりすると、何個もSandBoxアカウント（架空のメアドでOK）を作る必要があって面倒です。

またアンドロイドの場合は、アルファもしくはベータにバイナリ（アプリのデータ）をGoogle Play Consoleにアップロードして審査を通過してからでないと、課金のテストができません。

つまり、未完成だと色々面倒ってことです。


#### iOSの課金テストは実機が必要
iOSの課金テストは実機が必要です。実機をさわれない場合にはチェックができません。シュミレーターでは課金アイテムの表示まではできます。
ちなみに私は最終テスト用のTestFlightでないと課金テストができないと思っていたので、無駄にバージョンを刻んでしまいましたが、TestFlightまで進める必要はありません。

#### その他
※20年6月時点での情報をもとに作成しています。
※試行錯誤の結果、まだリファクタリング等できていません。処理が冗長的なところや一部無駄な処理もありますので、ご了承ください。


### 導入するパッケージ
**in_app_purchase**
[in_app_purchase | Flutter Package](https://pub.dev/packages/in_app_purchase)

こちらがFlutter公式の課金用のパッケージのようです。
もう一つあるようなのですが、今後を見据えてこちらを採用しました。
初期設定はGet Startを読んでください。

### 参考記事（教科書）
**in_app_purchase**が用意している下記のGitHubのサンプルコードを改造していくのがメインになります。こちらが今回の教科書です。

[plugins/main.dart at master · flutter/plugins · GitHub](https://github.com/flutter/plugins/tree/main/packages/in_app_purchase/in_app_purchase)

もう一つがサーバー側の処理としては下記の記事を参考にさせて頂きました。分かりやすくて素晴らしい記事でした。
[【Flutter + Firebase】アプリ内課金(IAP)のステップバイステップ実装ガイド【レシート検証】 | taketiyo.log](https://tkzo.jp/blog/flutter-iap-implementation/)

鬼門のiOSのレシート関連はこちらが分かりやすかったですね。
AppStoreが返してくる購入履歴がどんなものか理解できると思います。
https://qiita.com/Masataka-n/items/6f98a5a9fee7b28ccd1f


## サブスク課金機能の実装
前提の説明が長くなりましたが、早速、サブスク課金の実装を進めていきます。

#### 各ストアの設定

まず教科書はこちら。
**in_app_purchase**
[in_app_purchase | Flutter Package](https://pub.dev/packages/in_app_purchase)

まず面倒ですが、サブスク課金ができるようにパッケージのインストールを済ませたら、各ストアで サブスクアイテムを追加します。

この記事内にあるGet Startをまずやってみてください。

*  [In-App Purchase (App Store)](https://developer.apple.com/in-app-purchase/) 
*  [Google Play Biling Overview](https://developer.android.com/google/play/billing/billing_overview) 

#### サブスクアイテムの作成
#### AppStoreの場合
https://help.apple.com/app-store-connect/?lang=ja-jp#/devae49fb316
この記事を参考にすれば作成は難しくないはずです。

作成途中ステータスにメタデータがないと表示されるので、それがなくなるように各項目を入力していきます。

プロモーションオプションでは課金アイテムのアイコンを設定できますが、アプリのアイコンと同一はNGでした。またアイコン内の文字が見えづらかったり、小さいと却下くらいます。私はアプリのアイコンの上に重ねるようにプラン名を大きく記載しました。

審査に関する情報という項目があって、スクリーンショットを添付する必要があるのですが、ここは課金画面をスクショしてアップロードしたら良いようです。コメントには特に何も記載していません。

この時に、有料課金の詳細、プラン名と金額、利用規約とプライバシーポリシーがないと却下くらうみたいです。あと、AppStoreの説明にも書いておく必要があるので注意。

分からなかったら、僕のアプリを参考にしてみてください。

■AppStore
https://apps.apple.com/jp/app/id1517535550

#### GooglePlayの場合
サブスクアイテムを設定するには、APK（アプリのデータ）をアップロードする必要があります。
その際に、購入権限が許可されたAPKが必要と言われるので、下記をマニフェストに追加します。
ちなみにAPKの審査が通過してからでないと、アイテムが表示されないようです。

```/android/app/src/main/AndroidManifest.xml
<!—  定期購入アイテム作成のために、請求権限を追加　—>
<uses-permission android:name=“com.android.vending.BILLING” />
```
### iOSのサブスクアイテムの表示について
App Storeはバイナリ（アプリのデータ）をアップロードする必要はないとあったのですが、サブスクアイテムを登録後、３日ほど経っても表示されませんでした。

バイナリを登録したところ表示されたので、とりあえず一度バイナリをアップしておいた方がいいかもれません。

### サブスクアイテムの表示の仕組み
**in_app_purchase**が用意している下記のGitHubのサンプルコードを改造していくのがメインになります。こちらをまず読みこんでください。

[plugins/main.dart at master · flutter/plugins · GitHub](https://github.com/flutter/plugins/tree/main/packages/in_app_purchase/in_app_purchase)

定期購入・購読に表示する名前や金額はアプリ側で用意するのではなく、ストアからアイテムのデータを取得して表示します。

ストアから取得することによって、地域に合わせた通貨、ローカライズを登録しておけばローカライズされた名前と説明が表示されます。
![Simulator Screen Shot - iPhone 11 Pro Max - 2020-06-17 at 16.35.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228439/5c14d96d-2f7c-afad-e22c-a3d915451246.png)


だから、アプリ側で、金額やアイテム名などをローカライズをする必要がありません。

表示するアイテムの選択は以下の通り。

Github記事上部に書いてあるとおり、「_kProductIds」に、課金アイテムの製品ID（iOS）とアイテムID（Android）を設定します。

ここに記載されたIDのアイテムが取得されてきます。

```dart
//Githubに書いてあるサンプル
//それぞれ設定したIDを入力してください。
const String _kConsumableId = 'consumable';
const List<String> _kProductIds = <String>[
//以下にサンプルのidが記載されている
  _kConsumableId, //変数に予め入れておいた方が、あとでチェックなどしやすい。
  'upgrade', //これはサンプルなので、適宜IDに変えてください。
  'subscription'
];
```

両ストア同じIDにできるならいいのですが、同じにできない場合は下記のようにしてみてください。

```dart
List<String> _kProductIds = <String>[
  Platform.isIOS?'iosのid' : 'androidのid',
  Platform.isIOS?'iosのid' : 'androidのid',
];
```
ここで登録されたIDを元にデータが引っ張られてきます。

購入履歴の取得は、「initStoreInfo();」メソッド内でストアに問い合わせて、エラーがあった場合、データが空だった場合、過去の購入履歴のチェックなど、長い処理を経て、最後に「_products」に代入されます。

```dart
Future<void> initStoreInfo() async {
//省略
 await _connection.queryProductDetails(_kProductIds.toSet());
//省略
 _products = productDetailResponse.productDetails;
//省略
}
```


それから、「_buildProductList();」メソッドで取得してきたプロダクト情報を展開してカードとして表示しています。

### 購入処理について

####購入ボタンが押された時
購入処理は、_buildProductList();内にある下記のところで処理されます。

```dart
 Card _buildProductList(){
//省略
//_productsをそれぞれ展開して、productDetailsで詳細を表示
  return ListTile(
            title: Text(
              productDetails.title,
            ),
            subtitle: Text(
              productDetails.description,
            ),

            trailing: previousPurchase != null
                ? Icon(Icons.check)
                : FlatButton(
                    child: Text(productDetails.price),
                    color: Colors.green[800],
                    textColor: Colors.white,
                    onPressed: () {
                      PurchaseParam purchaseParam = PurchaseParam(
                          productDetails: productDetails,
                          applicationUserName: null,
                          sandboxTesting: true);
//消費型の購入か非消費型の購入の処理の分岐
//サブスクのみの場合は、_connection.buyConsumableの処理と分岐は不要
                      if (productDetails.id == _kConsumableId) {
                        _connection.buyConsumable(
                            purchaseParam: purchaseParam,
                            autoConsume: kAutoConsume || Platform.isIOS);
                      } else 
//サブスクの場合はこれを残す
                        _connection.buyNonConsumable(
                            purchaseParam: purchaseParam);
                      }
                    },
                  ));

//省略

}

```
「_connection.buyConsumable」と「connection.buyNonConsumable」を分岐で処理してますが、今回はサブスクリプションなので、**「connection.buyNonConsumable」**の処理だけで大丈夫です。
今回は月額と年額の期間の差で、サービスに差がないので、特に商品の区別をする必要がありません。


### 購入処理の実行
この処理を実行する、コードの一番下にある**「_listenToPurchaseUpdated]**メソッドが発動します。

ここで処理中にローディング画面を表示したり、エラーが発生した場合はエラー処理をします。

次に **「_verifyPurchase」**という処理で購入履歴の検証をしますが、この公式の例は鬼適当です。

この後に紹介する記事が丁寧に処理方法を紹介してくれるので、後ほど説明。

購入履歴が正統なものだったら**「deliverProduct」**が、正統なものと判断されない場合は**「_handleInvalidPurchase」**が呼ばれます。

**「deliverProduct」**では、別で作ってあるConsumableStoreのClassで、購入履歴を保存したりする処理をしています。
ただ、今回はサブスクのみであり、常に最新の購入履歴が期限内なのか確認する必要があるので、保存はしていません。
ローディング画面を閉じるくらいしか処理をしていません。有料機能の開放については、別の所で処理しています。


最後に、２つほど処理されます。

Androidnのみの処理は、Androidの方が消耗品タイプの場合、消費を通知しないと消費されたことにならないようです。

```dart
//最後から２番目の処理
   if (Platform.isAndroid) {
//month_planとyear_planにはそれぞれアイテムIDを代入済みです。
   if (!kAutoConsume && purchaseDetails.productID == _month_plan || purchaseDetails.productID == _year_plan) {
            await InAppPurchaseConnection.instance.consumePurchase(purchaseDetails);
       }
     }
```

最後の処理で、購入処理のトランザクジョン（一連の処理）を終了させます。

```dart
   if (purchaseDetails.pendingCompletePurchase) {
    await InAppPurchaseConnection.instance.completePurchase(purchaseDetails);
    }
```

また後で説明しますが、これがやっかいな問題を起こします。あとで書くはハマりポイントで紹介します。とりあえずこれはこれでOKです。


### 購入履歴の検証
**「_verifyPurchase」**の処理でレシートを検証します。
公式には詳細が書いていないので、下記の記事を参考にさせて頂きました。
素晴らしい記事をありがとうございます。

[【Flutter + Firebase】アプリ内課金(IAP)のステップバイステップ実装ガイド【レシート検証】 | taketiyo.log](https://tkzo.jp/blog/flutter-iap-implementation/)

このパートでは上記記事を参考に実装していきます。

このパートでは大きくわけて３つの処理をします。

#### 購入履歴検証の大まかな流れ

1.購入履歴のデータをサーバーにぶん投げる用に加工する
2.サーバーにぶん投げる
3.サーバーから返ってきた返答を処理する

という流れです。

#### 購入履歴の検証するサーバーについて
サーバー側の検証では、FirebaseのCloudFunctionsという機能を使います。
[Cloud Functions for Firebase](https://firebase.google.com/docs/functions?hl=ja)

簡単に言えば、ある特定のURLにデータをつけてアクセスすると、CloudFunctionsに内に書いてあるコード処理が実行されるという仕組みです。

使い方は、下記の記事内にあるYoutubeがめっちゃ分かりやすいです。
[はじめに: 最初の関数の記述、テスト、デプロイ  |  Firebase](https://firebase.google.com/docs/functions/get-started?hl=ja)

他でもすでにFirebaseの設定を済ませているので、下記をチェックしてFirebaseの設定などは済ませておいてください。
[Flutter アプリに Firebase を追加する](https://firebase.google.com/docs/flutter/setup?hl=ja)

#### サーバー内の各種設定およびデータの加工
教科書通りに進めていけば問題ありません。

#### CloudFunctions処理の微調整
教科書には丁寧に記載されているので、そのまま流用せてもらえれば問題ありません。

私の場合、CloudFunctionsのHowTo動画を見てからやったので、CloudFunctions内で動作させる言語は「TypeScript」を選択しています。（動画の先生が一押しするから）

そのためか、CloudFunction内の処理でうまく行かないところがありました。データを受け取った後に、値を取り出していくのですが、json形式ではないためエラーになってしまいました。
そこで一度json形式に再フォーマットをしました。

```typescript
//サーバー内の処理です。Flutter側ではありません。
//変更前
  const body = req.body;

//変更後
  const body = JSON.parse(req.body);

```

あとは特に問題はありません。
変更した所は、今回サブスクの更新のみなので、iOSは最新のレシートを取得すればいいので、下記のように変更しています。

```typescript
//サーバー内の処理です。Flutter側ではありません。
//iOS側の処理
//変更前
   const receiptCollections: Array<object> = result['receipt']['in_app'];
  if (result['latest_receipt_info']) {
    // `latest_receipt_info`は定期購読タイプのアイテムを購入したことがある場合のみ存在しています。
    // 送信された`receipt-data`に紐づくAppleアカウントから行われた、このアプリに関する全てのアイテムの購入履歴が格納されています。
    // 定期購読タイプのアイテムを1度でも購入したことがある場合、それ以外のアイテムの購入履歴もここに含まれる様になります。
    // ここでは`latest_receipt_info`が存在していた場合、`receipt.in_app`に含まれていた購入履歴とマージして返却しています。
    receiptCollections.concat(...result['latest_receipt_info']);
  }


//変更後
   var receiptCollections: Array<object> = result['latest_receipt_info'];
//最新のレシートだけ返却
```

#### iOSとアンドロイドの購入履歴の違い
ちょっとややこしいのが、OS毎の購入履歴の取り扱い方の違いです。

##### iOSの購入履歴の場合
サブスク購入があると、「latest_receipt_info」という最新のレシートを含んだ形で購入履歴を返してきます。。

iOSの購入履歴については下記がまさに最強の名がふさわしいこの記事を読んでおくとわかりやすいです。
https://qiita.com/Masataka-n/items/6f98a5a9fee7b28ccd1f


##### Androidの購入履歴の場合
こちらは最新の定期購入かを判断できているので、有効期限の判定をサーバー内で処理しています。


この記事を書いていて冷静に考えてみたら、iOS側もサーバーで処理できそうな感じがしてます笑

#### 購入履歴の検証後
各OS毎にレシートを検証して、何かしらのエラーや検証失敗となった場合、catch(e)に捕捉されて、falseを返却、処理が終了されます。

購入履歴検証が正常に終了した場合、trueが返されます。

その間に何かしら処理をが必要であれば処理しておきましょう。

リリースしたアプリでは全ての処理が終わったタイミングで、有料機能を開放するようにしています。

iOSだけ最新の購入履歴の情報が返ってきます。
その購入履歴の中に、期限をエポックミリ秒という時間を数字の変換している値が返ってくるので、それと現在時刻を比較しています。

```dart
Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) async {
//省略
//iOSの処理
        final decoded = json.decode(response.body);//サーバーから返ってきたデータ
        final transactions = decoded['transactions'];//必要なデータの取り出し
//expires_date_msが有効期限のエポックミリ秒
        int expires_date_ms =  await int.parse(transactions.last['expires_date_ms']);//int型に変更

//現在時刻を加工
        String  now =  await DateTime.now().toUtc().millisecondsSinceEpoch.toString();//現在時刻をエポックミリ秒に変換
        int now_ms = await int.parse(now);//int型に変換

//有効期限と現在時刻を比較
        if(expires_date_ms >= now_ms) { ///を過ぎたものはfalseに
          print("有効期限内です。");
         //有効だった場合の処理
        } else {
          print("有効期限が切れています。");
        //期限切れの処理
           return false;
        }

//省略
  return true; //一番最後に返してます。
}
```

### 有料機能の有効化
有料機能を有効化できると判断したら、どこかのタイミングで有効化の処理をいれることになります。
私は、「 _verifyPurchase」がtrueを返すタイミングで、有効化しています。

有効化した場合は、アプリを終了させても記録されているようにする必要があります。

DBに保存するという方法もありますが、私は下記のパッケージを使ってます。

#### shared_preferences
[shared_preferences | Flutter Package](https://pub.dev/packages/shared_preferences)
データベースではなく、設定情報などが消えずに保存できるパッケージです。

反対に有料機能を無効化する場合は、何度かプロセスを踏ませています。

というのは、テスト段階ではちょくちょくサーバーへの問い合わせが失敗することがありました。
そのため、安易に無効化処理を入れてしまうと、有効期限内なのに一時的に有料機能が無効化された状態になることがありました。

ユーザーのことを考えると、確実に通信が成功して有効期限が切れていると確証できる場合以外は、無効化処理をしないようにしています。


### サブスク更新チェック
一連の処理の中の最初の方、「initStoreInfo();」内で購入履歴を問い合わせ、購入履歴の検証をしています。

```dart
  Future<void> initStoreInfo() async {
//省略
   await _connection.queryPastPurchases();
    if (purchaseResponse.error != null) {
      // エラー処理
    }
    final List<PurchaseDetails> verifiedPurchases = [];
    for (PurchaseDetails purchase in purchaseResponse.pastPurchases) 
   //購入履歴の検証
      if (await _verifyPurchase(purchase)) {
   //サンプルでは購入したものとして、購入ボタンがチェック表示に変わる
   //購入済リストとして追加
        verifiedPurchases.add(purchase);
      }
    }

//省略

}
```
基本的にサブスクの更新チェックは、この一連の流れを繰り返すことになります。確認するだけなら不要な処理も含まれるので、別メソッドで切り出して、チェックの際はそちらを使う様にしています。

ちなみに、iOSの場合、最新の購入履歴が返ってくるので、無駄な処理な気はしてますね。

### 購入の復元ボタン
各ストアで購入の復元ボタンが必要そう。
ただ復元も結局、上記で紹介した処理を走らせてあげることで実現します。
公式のサンプルだと、特に復元ボタンがなくても、ログインした段階で、initstateで購入履歴が勝手に読み込まれてます。

ぶっちゃけ、ボタンはいらない気がしますが、審査を通すためにとりあえずつけてます。


### 重要！！課金機能でハマったポイント
今回課金機能の鬼門、実装してハマったポイントがあります。Androidは特に問題はなく動作したのですが、iOSでうまくいきませんでした。

#### 実機の課金テストで不具合
課金テストではSandBoxユーザーというテスト用アカウントを使って課金テストをします。しかし、以下の問題がありました。

- 課金ボタンを押しても反応しない
- 新しいSandBoxユーザーなのに課金履歴が存在する
- 最新の購入履歴が取得できず、ランダムで過去の購入履歴が取得されてしまう。


#### 原因はゾンビ トランザクションだと思われます。
結論から言います。

いろいろと調べた結果、上記の不具合は、トランザクション（一連の購入処理）が溜まっていくことが問題ではないかと考えています。

https://github.com/flutter/flutter/issues/32759#issuecomment-620947340


AppStoreの場合、トランザクションの完了処理を通知させてないと、未完了のトランザクションが溜まっていくようです。
さらに自動でクリアもされません。

購入が正常に完了した場合、

```dart
   if (purchaseDetails.pendingCompletePurchase) {
    await InAppPurchaseConnection.instance.completePurchase(purchaseDetails);
    }
```
この処理でトランザクションを終了させているようです。

しかし、エラーやその他分岐処理で終わった場合、トランザクションを完了させてません。

なので実装中に何度もエラーなんかを出しまくると、どんどんとゾンビ トランザクションが溜まっていくわけです。

上記の処理を呼べばいいのですが、処理したいタイミングで引数の(purchaseDetails)がない場合あるので、そんなに気軽によべない問題があります。

さらに最悪なのは、どうもAppStoreはアカウントだけでなく、端末でトランザクションを記録しているのか、紐づいているらしく、トランザクションが溜まりまくるとカオス化していきました。

一時的な解決方法は、新しくサブスクアイテムを作り直すことで、きれいになります。

ただし、根本的な解決には至らないので、トランザクションを削除するように処理を仕込んでいきます。。

####　ゾンビ トランザクション退治。
下記のトランザクションのキャンセル処理を、initstate時、購入時、エラー時におまじない的に仕込むことにしました。

ログを見ていたところトランザクションをひっぱり出して処理をしてくれているようです。
ただ、読み出すトランザクションの量が一定でなかったり、イマイチAppStoreの仕様が分かりません。
ちょっとまだ謎が残りますが、とにかくトランザクションをためないことが重要。

私の場合、これに気づいた時点で１週間くらいどハマりしていて、トランザクションが膨大に溜まっていたようで退治しきれず、新しくサブスクアイテムを作り直しました。

それ以降は、正常に動作しています。

トランザクションのキャンセル処理は以下のように実装しています。

```dart
  cancelTransction() async{
    print("トランザクションのキャンセルを実行");

    if (Platform.isIOS) {
      var paymentWrapper = SKPaymentQueueWrapper();
      var transactions = await paymentWrapper.transactions();
      for (var i = 0; i < transactions.length; i++) {
        print('トランザクションの削除を開始');
        print("${transactions[i].transactionIdentifier}:${transactions[i].payment.productIdentifier}");
        await paymentWrapper.finishTransaction(transactions[i]);
      }
    }
  }
```

### まとめ
いかがでしたか？

景気が悪くなり広告収益型のビジネスが稼ぐのが難しくなっています。
特にアプリ関連は、iOS14の登場でさらに悪化すると予想されます。

今後は無料で多くの人に使ってもらうよりも、本当に必要とする人に、有料でも使いたいと思えるアプリを提供することが重要となってくるのではないかと思っています。

だからこそ課金機能は、今回アプリを実装するうえでどうしても実装したい機能でした。
しかし、有料課金の仕組みの理解、実装、意味不明な不具合の影響で、２週間程度どハマりしてしまいました。

途中挫けそうになりましたが、ツイッターでプロ個人開発の[@atagon](https://twitter.com/atagon)あたかさんにアドバイスを頂いたり、助けていただきました。この場で改めてお礼を言いたいと思います。

ありがとうございました。

なんやかんやで、なんとか実装することができました。
とはいえ、まだまだ無駄な処理や、深く理解できていないところもあり、改良の予知があるなと、この記事を書いて実感しています。

初めて実装する場合、有料課金の概念が分かりづらかったりすると思います。
なので、まずどんな仕組みなのかをじっくり調べてから実装すると早いかもしれません。

もし、分からないことがあれば遠慮なくコメントに残してください。
わかることであれば、回答させていただきます。


あと、四苦八苦して作ったアプリも是非ともよろしくお願いします。アプリのアイデア出しに使ってみてください。

■AppStore
https://apps.apple.com/jp/app/id1517535550

■Google Play
https://play.google.com/store/apps/details?id=com.IdeaShuffleMemoApp&hl=ja

■アプリの詳細記事
https://yukio.site/idea_shuffle_memo/


あとツイッターもやってますので、ぜひチェックください。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">HackerMemoを大幅にアップデートしたので、<br><br>海外向けのPVを作ってみました😆<br><br>夜なべして頑張って作ったので、拡散頂けたら泣いて喜びます😭🙏<br><br>音有りがオススメです！<br><br>元ネタわかる人いるかな〜🤔<br><br>アプリはこちら🔽<br>■iPhoneの方<a href="https://t.co/6T10L94Ld1">https://t.co/6T10L94Ld1</a><br><br>■Androidの方<a href="https://t.co/uBevCXrNw2">https://t.co/uBevCXrNw2</a> <a href="https://t.co/wiGLv46kaG">pic.twitter.com/wiGLv46kaG</a></p>&mdash; YuKiO｜アプリ個人開発｜Flutter × Firebase | スケルカメラリリースしました (@oo_forward) <a href="https://twitter.com/oo_forward/status/1348103082769453056?ref_src=twsrc%5Etfw">January 10, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

■アプリ開発について音声でも配信しています。
https://radiotalk.jp/program/84583

<iframe width="460" height="187" src="https://radiotalk.jp/embed/662431?color=navy" frameborder="0" scrolling="no" allowfullscreen style="max-width: 100%;"></iframe>


<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">９８配信目！<a href="https://t.co/IziCvvfekO">https://t.co/IziCvvfekO</a><a href="https://twitter.com/hashtag/%E3%82%A2%E3%83%97%E3%83%AA%E9%96%8B%E7%99%BA?src=hash&amp;ref_src=twsrc%5Etfw">#アプリ開発</a> <a href="https://twitter.com/hashtag/%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0%E5%88%9D%E5%BF%83%E8%80%85?src=hash&amp;ref_src=twsrc%5Etfw">#プログラミング初心者</a> <a href="https://twitter.com/hashtag/%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0%E7%8B%AC%E5%AD%A6?src=hash&amp;ref_src=twsrc%5Etfw">#プログラミング独学</a></p>&mdash; YuKiO｜アプリ個人開発｜Flutter × Firebase | スケルカメラリリースしました (@oo_forward) <a href="https://twitter.com/oo_forward/status/1473501768080576520?ref_src=twsrc%5Etfw">December 22, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>