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..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,3 +1,9 @@ +## 0.4.6+3 + +* Fixes `finish()` method to always call its completion handler in StoreKit2. +* `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 * 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..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 @@ -263,10 +263,21 @@ 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() + do { + let transaction = try await fetchUnfinishedTransaction(by: UInt64(id)) + if let transaction = transaction { + await transaction.finish() + } + // 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( + PigeonError( + code: "storekit2_finish_transaction_failed", + message: "Failed to finish transaction: \(error.localizedDescription)", + details: "Transaction ID: \(id)"))) } } } @@ -362,7 +373,24 @@ extension InAppPurchasePlugin: InAppPurchase2API { return transactions } - /// Helper function to fetch specific transaction + /// 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 { + return transaction + } + case .unverified: + continue + } + } + return nil + } + + /// 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 { 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..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,6 +415,67 @@ final class InAppPurchase2PluginTests: XCTestCase { await fulfillment(of: [finishExpectation], timeout: 5) } + 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 success") + + plugin.finish(id: 999999) { result in + switch result { + case .success(): + finishExpectation.fulfill() + case .failure(let error): + XCTFail("Finish should succeed for non-existent transaction: \(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