はじめに (対象読者・この記事でわかること)
この記事は、FlutterでiOSアプリを開発し、in_app_purchaseパッケージを使ってアプリ内課金を実装しているが「There is a pending transaction for the same product identifier」というエラーでハマっている開発者向けです。
この記事を読むことで、
- なぜこのエラーが発生するのか
- どうやって再現・回避するのか
- 実装レベルでどう安全にトランザクションを完結させるか
が実装コードベースで理解できます。自分も審査直前にこのエラーで3日間消えたので、同じ時間を無駄にしたくない方の助けになればと思います。
前提知識
- Flutter/Dartの基礎的な文法が読める
- in_app_purchaseパッケージを
pubspec.yamlに追加済みで、至少くも「購入ボタンを押すと何かが動く」状態まで実装済み - iOS開発者アカウントとApp Store Connectで消耗型/非消耗型プロダクトが作成済み
- StoreKit 2ではなく、従来のStoreKit 1(=in_app_purchaseが内部的に使っている)を対象とする
「pending transaction」とは何か〜StoreKitの仕組みから考える
iOSの課金フローは「ユーザーが購入→App Storeがトランザクションを生成→アプリがそのトランザクションをfinishTransactionで完結」までが1セットです。
この「完結」が呼ばれないと、StoreKitは「このプロダクトはまだ配信が終わっていない」と判断し、次回SKPaymentQueueにアプリが接続した際に同じプロダクトIDで再びトランザクションを復元します。
Flutterのin_app_purchaseはpurchaseStreamにそのまま流してくるため、結果的に
There is a pending transaction for the same product identifier (product_id)
というPlatformExceptionがDart層に上がってきます。
要するに「前のトランザクションが finish されていない」だけの話ですが、以下のようなケースで嵌まりやすくなります。
- サーバー検証を経由する設計で、サーバーが500エラーを返してアプリがfinishを呼ばなかった
- 購入直後にクラッシュ or 強制終了
- 開発中にXcodeでアンインストール→再インストールを繰り返し、トランザクションが残ったままになった
- Sandboxテスターを使いまわしており、別デバイスで同じサンドボックスアカウントが未完了トランザクションを保持している
実装レベルで安全にトランザクションを完結させる方法
ステップ1: アプリ起動時に「積まれているトランザクション」を全部処理する
in_app_purchaseのpurchaseStreamはアプリ起動直後から自動的に未完了トランザクションを流してくれます。
これを利用して、いかなる購入UIを表示する前に以下のように「前回のトランザクションを片付ける」ロジックを呼び出します。
Dartclass PurchaseRepository { final InAppPurchase _iap = InAppPurchase.instance; late StreamSubscription<List<PurchaseDetails>> _subscription; Future<void> init() async { // 1. ストアが利用可能かチェック final bool isAvailable = await _iap.isAvailable(); if (!isAvailable) return; // 2. StreamをListen(この時点でpendingトランザクションが流れてくる) _subscription = _iap.purchaseStream.listen( _handlePurchaseUpdate, onError: (Object error) => _handleError(error), ); // 3. 既存の購入情報を「復元」してStreamに流す // (消耗品は復元できないが、非消耗品やサブスクリプションは履歴が流れる) await _iap.restorePurchases(); } void _handlePurchaseUpdate(List<PurchaseDetails> purchases) { for (final purchase in purchases) { if (purchase.status == PurchaseStatus.pending) { // 4. まだApp Store側で処理中なので何もしない continue; } if (purchase.pendingCompletePurchase) { // 5. サーバー検証などビジネスロジックを実行 _verifyAndDeliver(purchase).then((success) async { if (success) { // 6. 必ずfinishを呼ぶ await _iap.completePurchase(purchase); } }); } } } Future<bool> _verifyAndDeliver(PurchaseDetails purchase) async { try { final receipt = purchase.verificationData.localVerificationData; // 自身のサーバーに送信して検証 final res = await http.post( Uri.parse('https://api.example.com/verify'), body: jsonEncode({'receipt': receipt}), ); return res.statusCode == 200; } catch (_) { return false; } } void _handleError(Object error) { // ログを送信したり、Crashlyticsに記録 developer.log('IAP Error: $error'); } void dispose() { _subscription.cancel(); } }
ポイントは
purchaseStreamを常にListenしておくことpendingCompletePurchase == trueならば必ずcompletePurchaseを呼ぶこと- サーバー検証で失敗しても、リトライ回数に上限を設けて最終的には
completePurchaseを呼ぶこと(再払い防止のため)
ステップ2: 購入フロー中も「完了」するまで次の購入をブロックする
ユーザーは連打する生き物です。以下のように「今トランザクション中です」フラグを立てて、完了 or エラーまでボタンを無効化します。
Dartclass PurchaseButton extends StatefulWidget { final String productId; const PurchaseButton(this.productId, {Key? key}) : super(key: key); @override _PurchaseButtonState createState() => _PurchaseButtonState(); } class _PurchaseButtonState extends State<PurchaseButton> { bool _isProcessing = false; Future<void> _buy() async { if (_isProcessing) return; setState(() => _isProcessing = true); final PurchaseParam purchaseParam = PurchaseParam( productDetails: await _fetchProduct(widget.productId), applicationUserName: null, ); // 購入リクエストを出す await InAppPurchase.instance.buyNonConsumable( purchaseParam: purchaseParam, ); // Stream listenerで完了 or エラーを受け取るまで待機 // 実装によりますが、ここでは簡単のためCompleterを使う例 // ... } @override Widget build(BuildContext context) { return ElevatedButton( onPressed: _isProcessing ? null : _buy, child: Text(_isProcessing ? '処理中...' : 'アップグレード'), ); } }
これで「同じプロダクトIDで2重に購入リクエストが飛ぶ」ことがなくなります。
ハマった点・エラー解決
1. サンドボックスで「謎のpendingトランザクション」が永遠に残る
Sandbox環境ではレシート検証をスキップしても必ずcompletePurchaseを呼ばないと、次回アプリ起動時に永遠に流れてきます。
開発中は「レシート検証失敗→何もしない」で終わらせがちなので注意です。
2. iOS 15+でSKPaymentQueueのshowPriceConsentIfNeededが出て購入フローが止まる
StoreKit 1では稀に「金額同意」ダイアログが出て操作不能になることがあります。
in_app_purchase 0.7.0以降で修正済みですが、もし古いバージョンを使っている場合はアップグレードしてください。
3. TestFlightでは完結してもApp Store審査で「pendingがある」と指摘される
審査担当者は複垢・複デバイスで検証します。
トランザクションをサーバーで管理している場合は、トランザクションIDごとに一意に完了フラグを持ち、2度目のcompletePurchaseを呼ばないようにするだけで指摘を回避できます。
解決策まとめ
- 必ず
purchaseStreamをListenし続ける pendingCompletePurchase == trueならcompletePurchaseを呼ぶ- サーバー検証失敗時もリトライ上限(例:3回)を超えたらfinishして「払戻し or 再購入」を促す仕組みにする
- 消耗品でも
restorePurchasesを呼ぶことで、稀に残るトランザクションを引き上げられる - 審査前にサンドボックスアカウントを作り直し、未完了トランザクションをリセットする(App Store Connect → ユーザとアクセス → Sandbox)
まとめ
本記事では、Flutterでin_app_purchaseを使ったiOSアプリ内課金で「There is a pending transaction for the same product identifier」エラーが出た際の原因と、実装レベルで安全にトランザクションを完結させる方法を解説しました。
- pendingトランザクションは「finishしていない」だけ
- アプリ起動時に
purchaseStreamで片付ける - 購入フロー中は次の購入をブロックする
- サーバー検証失敗時も最終的に
completePurchaseを呼ぶ
この記事を通して、読者の皆さんが審査直前の「pending地獄」から解放され、スムーズにリリースできることを願っています。
次回は、同じトランザクションをStoreKit 2とServer-to-Server Notificationでより堅牢に管理する方法を紹介したいと思います。
参考資料
- in_app_purchase | Flutter Package
- Apple Developer – StoreKit 1 Guide
- Flutter公式 - Adding in-app purchases
- Qiita - iOS サンドボックス課金でトランザクションが残る話
