From 814e600ca379f36ffc09a876f57fc90b344a7a63 Mon Sep 17 00:00:00 2001 From: Arun Date: Wed, 26 Nov 2025 15:02:26 +0530 Subject: [PATCH 1/4] [in_app_purchase_storekit] Fix consumable repurchase issue in StoreKit2 Fixes https://github.com/flutter/flutter/issues/179111 ## Changes - Fixed inish() method to always call the completion handler, even when the transaction is not found - Added fallback to Transaction.unfinished when looking up transactions - Added proper error handling with PigeonError for transaction finish failures - Added tests for non-existent transaction finish and consumable repurchase scenarios ## Problem With StoreKit2, consumable products could only be purchased once because: 1. The inish() method would never call its completion handler if the transaction wasn't found in Transaction.all 2. This caused the Dart side to hang indefinitely 3. Consumable transactions needed to be found in Transaction.unfinished as well ## Solution - inish() now returns success even if the transaction is not found (it's effectively already complete) - etchTransaction() now checks both Transaction.all and Transaction.unfinished - Proper error handling added for unexpected failures --- .../in_app_purchase_storekit/CHANGELOG.md | 6 ++ .../InAppPurchasePlugin+StoreKit2.swift | 42 +++++++++++-- .../InAppPurchaseStoreKit2PluginTests.swift | 61 +++++++++++++++++++ .../in_app_purchase_storekit/pubspec.yaml | 2 +- 4 files changed, 105 insertions(+), 6 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md index 60407b4c907..56e6b8139ce 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.4.6+3 + +* Fixes consumable products not being repurchasable after the first purchase in StoreKit2. +* Ensures `finish()` always completes even when the transaction is not found in the transaction history. +* Improves transaction lookup to also check `Transaction.unfinished` for consumables. + ## 0.4.6+2 * Updates to Pigeon 26. diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift index da8874e0a1c..a54d5823bac 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift @@ -263,10 +263,24 @@ extension InAppPurchasePlugin: InAppPurchase2API { /// Wrapper method around StoreKit2's finish() method https://developer.apple.com/documentation/storekit/transaction/3749694-finish func finish(id: Int64, completion: @escaping (Result) -> Void) { Task { - let transaction = try await fetchTransaction(by: UInt64(id)) - if let transaction = transaction { - await transaction.finish() - completion(.success(Void())) + do { + let transaction = try await fetchTransaction(by: UInt64(id)) + if let transaction = transaction { + await transaction.finish() + completion(.success(Void())) + } else { + // Transaction not found - this can happen for consumables that have + // already been finished or are no longer in the transaction history. + // We still return success as the transaction is effectively complete. + completion(.success(Void())) + } + } catch { + completion( + .failure( + PigeonError( + code: "storekit2_finish_transaction_failed", + message: "Failed to finish transaction: \(error.localizedDescription)", + details: "Transaction ID: \(id)"))) } } } @@ -362,8 +376,11 @@ extension InAppPurchasePlugin: InAppPurchase2API { return transactions } - /// Helper function to fetch specific transaction + /// Helper function to fetch specific transaction by ID. + /// First checks Transaction.all, then falls back to unfinished transactions + /// to ensure consumable transactions can be found and finished. func fetchTransaction(by id: UInt64) async throws -> Transaction? { + // First, try to find in Transaction.all for await result in Transaction.all { switch result { case .verified(let transaction): @@ -374,6 +391,21 @@ extension InAppPurchasePlugin: InAppPurchase2API { continue } } + + // If not found in Transaction.all, check unfinished transactions + // This is important for consumables that may have been purchased + // but not yet iterated through Transaction.all + for await result in Transaction.unfinished { + switch result { + case .verified(let transaction): + if transaction.id == id { + return transaction + } + case .unverified: + continue + } + } + return nil } } diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift index 4b95b5329bb..433c9f0e82b 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift @@ -415,6 +415,67 @@ final class InAppPurchase2PluginTests: XCTestCase { await fulfillment(of: [finishExpectation], timeout: 5) } + func testFinishNonExistentTransactionSucceeds() async throws { + // Test that finishing a non-existent transaction returns success + // This is important for consumables that may have already been finished + // or are no longer in the transaction history + let finishExpectation = self.expectation(description: "Finishing non-existent transaction should succeed") + + plugin.finish(id: 999999) { result in + switch result { + case .success(): + finishExpectation.fulfill() + case .failure(let error): + XCTFail("Finish should NOT fail for non-existent transaction. Failed with \(error)") + } + } + + await fulfillment(of: [finishExpectation], timeout: 5) + } + + func testConsumableCanBeRepurchasedAfterFinish() async throws { + // Test that a consumable can be purchased again after finishing + let firstPurchaseExpectation = self.expectation(description: "First purchase should succeed") + let finishExpectation = self.expectation(description: "Finishing purchase should succeed") + let secondPurchaseExpectation = self.expectation(description: "Second purchase should succeed") + + // First purchase + plugin.purchase(id: "consumable", options: nil) { result in + switch result { + case .success: + firstPurchaseExpectation.fulfill() + case .failure(let error): + XCTFail("First purchase should NOT fail. Failed with \(error)") + } + } + + await fulfillment(of: [firstPurchaseExpectation], timeout: 5) + + // Finish the transaction + plugin.finish(id: 0) { result in + switch result { + case .success(): + finishExpectation.fulfill() + case .failure(let error): + XCTFail("Finish should NOT fail. Failed with \(error)") + } + } + + await fulfillment(of: [finishExpectation], timeout: 5) + + // Second purchase - this should also succeed + plugin.purchase(id: "consumable", options: nil) { result in + switch result { + case .success: + secondPurchaseExpectation.fulfill() + case .failure(let error): + XCTFail("Second purchase should NOT fail. Failed with \(error)") + } + } + + await fulfillment(of: [secondPurchaseExpectation], timeout: 5) + } + @available(iOS 18.0, macOS 15.0, *) func testIsWinBackOfferEligibleEligible() async throws { let purchaseExpectation = self.expectation(description: "Purchase should succeed") diff --git a/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml index cb642ec20ae..a3503b15a1d 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_storekit description: An implementation for the iOS and macOS platforms of the Flutter `in_app_purchase` plugin. This uses the StoreKit Framework. repository: https://github.com/flutter/packages/tree/main/packages/in_app_purchase/in_app_purchase_storekit issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.4.6+2 +version: 0.4.6+3 environment: sdk: ^3.9.0 From cc0994d76c783da7a857af1ce3d4a385728d8b28 Mon Sep 17 00:00:00 2001 From: Arun Date: Thu, 27 Nov 2025 11:38:14 +0530 Subject: [PATCH 2/4] Fix Swift formatting issues --- .../StoreKit2/InAppPurchasePlugin+StoreKit2.swift | 4 ++-- .../RunnerTests/InAppPurchaseStoreKit2PluginTests.swift | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift index a54d5823bac..e9ef0bf0e68 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift @@ -391,7 +391,7 @@ extension InAppPurchasePlugin: InAppPurchase2API { continue } } - + // If not found in Transaction.all, check unfinished transactions // This is important for consumables that may have been purchased // but not yet iterated through Transaction.all @@ -405,7 +405,7 @@ extension InAppPurchasePlugin: InAppPurchase2API { continue } } - + return nil } } diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift index 433c9f0e82b..4e726d10995 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift @@ -419,7 +419,8 @@ final class InAppPurchase2PluginTests: XCTestCase { // Test that finishing a non-existent transaction returns success // This is important for consumables that may have already been finished // or are no longer in the transaction history - let finishExpectation = self.expectation(description: "Finishing non-existent transaction should succeed") + let finishExpectation = self.expectation( + description: "Finishing non-existent transaction should succeed") plugin.finish(id: 999999) { result in switch result { From d031e4699ea9b499b4f92d248b5ee7eb03d12a4d Mon Sep 17 00:00:00 2001 From: Arun Date: Tue, 2 Dec 2025 11:35:08 +0530 Subject: [PATCH 3/4] Address reviewer feedback: use Transaction.unfinished and return error for non-existent transactions Changes based on @LongCatIsLooong's review: - Use Transaction.unfinished instead of Transaction.all for finish() since we're looking for transactions that need to be finished - Return an error when attempting to finish a non-existent transaction instead of silently succeeding - Added fetchUnfinishedTransaction() helper specifically for finish() - Updated tests to expect error for non-existent transaction --- .../in_app_purchase_storekit/CHANGELOG.md | 6 ++-- .../InAppPurchasePlugin+StoreKit2.swift | 34 ++++++++++--------- .../InAppPurchaseStoreKit2PluginTests.swift | 14 ++++---- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md index 56e6b8139ce..c10bb16e6bd 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md @@ -1,8 +1,8 @@ ## 0.4.6+3 -* Fixes consumable products not being repurchasable after the first purchase in StoreKit2. -* Ensures `finish()` always completes even when the transaction is not found in the transaction history. -* Improves transaction lookup to also check `Transaction.unfinished` for consumables. +* Fixes `finish()` method to always call its completion handler in StoreKit2. +* `finish()` now returns an error when the transaction is not found in unfinished transactions. +* Adds `fetchUnfinishedTransaction()` helper for looking up transactions that need to be completed. ## 0.4.6+2 diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift index e9ef0bf0e68..005835dd40d 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift @@ -264,15 +264,18 @@ extension InAppPurchasePlugin: InAppPurchase2API { func finish(id: Int64, completion: @escaping (Result) -> Void) { Task { do { - let transaction = try await fetchTransaction(by: UInt64(id)) + let transaction = try await fetchUnfinishedTransaction(by: UInt64(id)) if let transaction = transaction { await transaction.finish() completion(.success(Void())) } else { - // Transaction not found - this can happen for consumables that have - // already been finished or are no longer in the transaction history. - // We still return success as the transaction is effectively complete. - completion(.success(Void())) + // Transaction not found in unfinished transactions + completion( + .failure( + PigeonError( + code: "storekit2_transaction_not_found", + message: "Transaction not found in unfinished transactions.", + details: "Transaction ID: \(id)"))) } } catch { completion( @@ -376,12 +379,10 @@ extension InAppPurchasePlugin: InAppPurchase2API { return transactions } - /// Helper function to fetch specific transaction by ID. - /// First checks Transaction.all, then falls back to unfinished transactions - /// to ensure consumable transactions can be found and finished. - func fetchTransaction(by id: UInt64) async throws -> Transaction? { - // First, try to find in Transaction.all - for await result in Transaction.all { + /// Helper function to fetch specific transaction by ID from unfinished transactions. + /// Used by finish() to find transactions that need to be completed. + func fetchUnfinishedTransaction(by id: UInt64) async throws -> Transaction? { + for await result in Transaction.unfinished { switch result { case .verified(let transaction): if transaction.id == id { @@ -391,11 +392,13 @@ extension InAppPurchasePlugin: InAppPurchase2API { continue } } + return nil + } - // If not found in Transaction.all, check unfinished transactions - // This is important for consumables that may have been purchased - // but not yet iterated through Transaction.all - for await result in Transaction.unfinished { + /// Helper function to fetch specific transaction by ID from all transactions. + /// Used for general transaction lookups. + func fetchTransaction(by id: UInt64) async throws -> Transaction? { + for await result in Transaction.all { switch result { case .verified(let transaction): if transaction.id == id { @@ -405,7 +408,6 @@ extension InAppPurchasePlugin: InAppPurchase2API { continue } } - return nil } } diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift index 4e726d10995..8447196663e 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift @@ -415,19 +415,19 @@ final class InAppPurchase2PluginTests: XCTestCase { await fulfillment(of: [finishExpectation], timeout: 5) } - func testFinishNonExistentTransactionSucceeds() async throws { - // Test that finishing a non-existent transaction returns success - // This is important for consumables that may have already been finished - // or are no longer in the transaction history + func testFinishNonExistentTransactionReturnsError() async throws { + // Test that finishing a non-existent transaction returns an error let finishExpectation = self.expectation( - description: "Finishing non-existent transaction should succeed") + description: "Finishing non-existent transaction should return error") plugin.finish(id: 999999) { result in switch result { case .success(): - finishExpectation.fulfill() + XCTFail("Finish should fail for non-existent transaction") case .failure(let error): - XCTFail("Finish should NOT fail for non-existent transaction. Failed with \(error)") + let pigeonError = error as! PigeonError + XCTAssertEqual(pigeonError.code, "storekit2_transaction_not_found") + finishExpectation.fulfill() } } From 9f90cc6f61b0e1bfdd8d355e85a655fd079f9015 Mon Sep 17 00:00:00 2001 From: Arun Date: Fri, 5 Dec 2025 15:56:14 +0530 Subject: [PATCH 4/4] Return success instead of error when transaction not found in finish() If a transaction is not found in Transaction.unfinished, it means the transaction has already been finished. This should be treated as a success case rather than an error, since the desired outcome (transaction completed) has been achieved. This ensures that calling completePurchase() multiple times or on already-finished transactions works correctly, allowing consumable products to be repurchased. --- .../in_app_purchase_storekit/CHANGELOG.md | 2 +- .../StoreKit2/InAppPurchasePlugin+StoreKit2.swift | 12 +++--------- .../InAppPurchaseStoreKit2PluginTests.swift | 13 ++++++------- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md index c10bb16e6bd..6fea10d149d 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md @@ -1,7 +1,7 @@ ## 0.4.6+3 * Fixes `finish()` method to always call its completion handler in StoreKit2. -* `finish()` now returns an error when the transaction is not found in unfinished transactions. +* `finish()` now returns success when the transaction is not found in unfinished transactions (since it's already complete). * Adds `fetchUnfinishedTransaction()` helper for looking up transactions that need to be completed. ## 0.4.6+2 diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift index 005835dd40d..d3a3080eb07 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift @@ -267,16 +267,10 @@ extension InAppPurchasePlugin: InAppPurchase2API { let transaction = try await fetchUnfinishedTransaction(by: UInt64(id)) if let transaction = transaction { await transaction.finish() - completion(.success(Void())) - } else { - // Transaction not found in unfinished transactions - completion( - .failure( - PigeonError( - code: "storekit2_transaction_not_found", - message: "Transaction not found in unfinished transactions.", - details: "Transaction ID: \(id)"))) } + // If transaction is not found, it means it's already been finished. + // This is a success case - the transaction is complete. + completion(.success(Void())) } catch { completion( .failure( diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift index 8447196663e..480cf1333ad 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift @@ -415,19 +415,18 @@ final class InAppPurchase2PluginTests: XCTestCase { await fulfillment(of: [finishExpectation], timeout: 5) } - func testFinishNonExistentTransactionReturnsError() async throws { - // Test that finishing a non-existent transaction returns an error + func testFinishNonExistentTransactionReturnsSuccess() async throws { + // Test that finishing a non-existent transaction returns success + // (since if it's not in unfinished transactions, it's already complete) let finishExpectation = self.expectation( - description: "Finishing non-existent transaction should return error") + description: "Finishing non-existent transaction should return success") plugin.finish(id: 999999) { result in switch result { case .success(): - XCTFail("Finish should fail for non-existent transaction") - case .failure(let error): - let pigeonError = error as! PigeonError - XCTAssertEqual(pigeonError.code, "storekit2_transaction_not_found") finishExpectation.fulfill() + case .failure(let error): + XCTFail("Finish should succeed for non-existent transaction: \(error)") } }