diff --git a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md index 4609d6cf30b6..6e6a17cbc222 100644 --- a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md @@ -1,10 +1,11 @@ -## NEXT +## 3.2.4 * Updates minimum supported SDK version to Flutter 3.35/Dart 3.9. * Updates README to reflect currently supported OS versions for the latest versions of the endorsed platform implementations. * Applications built with older versions of Flutter will continue to use compatible versions of the platform implementations. +* Clarifies `completePurchase` usage and the consequences of unfinished transactions in the README and docstrings. ## 3.2.3 * Updates minimum `in_app_purchase_storekit` version to 0.4.0. diff --git a/packages/in_app_purchase/in_app_purchase/README.md b/packages/in_app_purchase/in_app_purchase/README.md index 79b870145493..8f3b1c9cdcaa 100644 --- a/packages/in_app_purchase/in_app_purchase/README.md +++ b/packages/in_app_purchase/in_app_purchase/README.md @@ -48,6 +48,8 @@ can start using the plugin. Two basic options are available: 1. A generic, idiomatic Flutter API: [in_app_purchase](https://pub.dev/documentation/in_app_purchase/latest/in_app_purchase/in_app_purchase-library.html). This API supports most use cases for loading and making purchases. + + > **NOTE**: On iOS and macOS, the generic API uses StoreKit 2 by default. If you need to fall back to StoreKit 1, call `InAppPurchaseStoreKitPlatform.enableStoreKit1()` before registering the platform. 2. Platform-specific Dart APIs: [store_kit_wrappers](https://pub.dev/documentation/in_app_purchase_storekit/latest/store_kit_wrappers/store_kit_wrappers-library.html) and [billing_client_wrappers](https://pub.dev/documentation/in_app_purchase_android/latest/billing_client_wrappers/billing_client_wrappers-library.html). @@ -196,6 +198,24 @@ if (_isConsumable(productDetails)) { // Updates will be delivered to the `InAppPurchase.instance.purchaseStream`. ``` +StoreKit 2 Specific Purchases (iOS/macOS) +When StoreKit 2 is enabled, you can use Sk2PurchaseParam to include StoreKit 2 specific parameters such as win-back offer identifiers or promotional offers with signatures. + +```dart +import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart'; + +final productDetails = ...; // Obtained from queryProductDetails + +final purchaseParamSk2 = Sk2PurchaseParam( + productDetails: productDetails, + winBackOfferId: 'your_win_back_offer_id', +); + +await InAppPurchase.instance.buyNonConsumable( + purchaseParam: purchaseParamSk2, +); +``` + ### Completing a purchase The `InAppPurchase.purchaseStream` will send purchase updates after initiating @@ -209,7 +229,9 @@ purchase and the store can proceed to finalize the transaction and bill the end user's payment account. > **Warning:** Failure to call `InAppPurchase.completePurchase` and -> get a successful response within 3 days of the purchase will result a refund. +> get a successful response within 3 days of the purchase will result in a refund on Android. On iOS/macOS (using StoreKit 1 or StoreKit 2), failing to complete purchases causes the transactions to remain in Apple's unfinished transaction queue, which has two major side effects: +> 1. The transactions will be repeatedly re-delivered on the `purchaseStream` on every app launch. +> 2. Any subsequent attempt to purchase the same product ID will immediately fail with an error indicating a duplicate transaction is pending. ### Upgrading or downgrading an existing in-app subscription diff --git a/packages/in_app_purchase/in_app_purchase/lib/in_app_purchase.dart b/packages/in_app_purchase/in_app_purchase/lib/in_app_purchase.dart index 4bc5f9142200..d518ca4224cc 100644 --- a/packages/in_app_purchase/in_app_purchase/lib/in_app_purchase.dart +++ b/packages/in_app_purchase/in_app_purchase/lib/in_app_purchase.dart @@ -173,6 +173,14 @@ class InAppPurchase implements InAppPurchasePlatformAdditionProvider { /// For convenience, [PurchaseDetails.pendingCompletePurchase] indicates if a /// purchase is pending for completion. /// + /// iOS/macOS Warning: + /// If you do not call [completePurchase] for a transaction, that transaction + /// will remain in Apple's unfinished transaction queue. This has two consequences: + /// 1. The transaction will be repeatedly re-delivered on the [purchaseStream] + /// every time the app is restarted. + /// 2. Any subsequent attempts to buy the same product ID will fail with a purchase + /// error indicating a duplicate transaction is pending. + /// /// The method will throw a [PurchaseException] when the purchase could not be /// finished. Depending on the [PurchaseException.errorCode] the developer /// should try to complete the purchase via this method again, or retry the diff --git a/packages/in_app_purchase/in_app_purchase/pubspec.yaml b/packages/in_app_purchase/in_app_purchase/pubspec.yaml index 6d8a8cf99f23..4742be41c839 100644 --- a/packages/in_app_purchase/in_app_purchase/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase description: A Flutter plugin for in-app purchases. Exposes APIs for making in-app purchases through the App Store and Google Play. repository: https://github.com/flutter/packages/tree/main/packages/in_app_purchase/in_app_purchase issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 3.2.3 +version: 3.2.4 environment: sdk: ^3.9.0 diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_platform_interface/CHANGELOG.md index 7468310ea497..775474f40e77 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/CHANGELOG.md @@ -1,6 +1,7 @@ -## NEXT +## 1.4.1 * Updates minimum supported SDK version to Flutter 3.35/Dart 3.9. +* Clarifies `completePurchase` usage and the consequences of unfinished transactions in the README and docstrings. ## 1.4.0 diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/in_app_purchase_platform.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/in_app_purchase_platform.dart index eac2a80e214f..fb293482f7e9 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/in_app_purchase_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/in_app_purchase_platform.dart @@ -161,6 +161,14 @@ abstract class InAppPurchasePlatform extends PlatformInterface { /// For convenience, [PurchaseDetails.pendingCompletePurchase] indicates if a /// purchase is pending for completion. /// + /// **iOS/macOS Warning:** + /// If you do not call [completePurchase] for a transaction, that transaction + /// will remain in Apple's unfinished transaction queue. This has two consequences: + /// 1. The transaction will be repeatedly re-delivered on the [purchaseStream] + /// every time the app is restarted. + /// 2. Any subsequent attempts to buy the same product ID will fail with a purchase + /// error indicating a duplicate transaction is pending. + /// /// The method will throw a [PurchaseException] when the purchase could not be /// finished. Depending on the [PurchaseException.errorCode] the developer /// should try to complete the purchase via this method again, or retry the diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_platform_interface/pubspec.yaml index 4ebb53da84cc..d4660a55e979 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/packages/tree/main/packages/in_app_purcha issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 1.4.0 +version: 1.4.1 environment: sdk: ^3.9.0 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 1d56456408e0..c2c00546bd73 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md @@ -1,6 +1,12 @@ +## 0.4.8+2 + +* Clarifies `completePurchase` usage and the consequences of unfinished transactions in the README and API docstrings. +* Prevents duplicate purchase attempts in StoreKit 2 by throwing a `storekit_duplicate_product_object` error when a product already has an unfinished transaction. + ## 0.4.8+1 * Fixes StoreKit 2 purchase flow to send cancelled/pending/unverified results to `purchaseStream`. + ## 0.4.8 * Fixes an issue causing StoreKit2 purchases to be reported as `restored` and left in an 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 3b4ceac802c8..dd5db6a3342a 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 @@ -85,6 +85,22 @@ extension InAppPurchasePlugin: InAppPurchase2API { } } + for await verificationResult in Transaction.unfinished { + switch verificationResult { + case .verified(let transaction): + if transaction.productID == id { + let error = PigeonError( + code: "storekit_duplicate_product_object", + message: + "There is a pending transaction for the same product identifier. Please either wait for it to be finished or finish it manually using `completePurchase` to avoid edge cases.", + details: id) + return completion(.failure(error)) + } + case .unverified: + break + } + } + let result = try await product.purchase(options: purchaseOptions) switch result { diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit_objc/messages.g.m b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit_objc/messages.g.m index 7be9e75eb517..ae7ef1937a2e 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit_objc/messages.g.m +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit_objc/messages.g.m @@ -733,11 +733,11 @@ void SetUpFIAInAppPurchaseAPIWithSuffix(id binaryMesseng binaryMessenger:binaryMessenger codec:FIAGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(startProductRequestProductIdentifiers: - completion:)], - @"FIAInAppPurchaseAPI api (%@) doesn't respond to " - @"@selector(startProductRequestProductIdentifiers:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector(startProductRequestProductIdentifiers:completion:)], + @"FIAInAppPurchaseAPI api (%@) doesn't respond to " + @"@selector(startProductRequestProductIdentifiers:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; NSArray *arg_productIdentifiers = GetNullableObjectAtIndex(args, 0); diff --git a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a65139025b00..d9fb4e2662c8 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/in_app_purchase/in_app_purchase_storekit/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -44,6 +44,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES" codeCoverageEnabled = "YES"> @@ -73,6 +74,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" 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 6440b374e41b..0a899b575c9f 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 @@ -547,4 +547,31 @@ final class InAppPurchase2PluginTests: XCTestCase { await fulfillment(of: [expectation], timeout: 5) } + func testDuplicatePurchaseFails() async throws { + let firstPurchaseExpectation = self.expectation(description: "First purchase should succeed") + let secondPurchaseExpectation = self.expectation(description: "Second purchase should fail") + + 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) + + plugin.purchase(id: "consumable", options: nil) { result in + switch result { + case .success: + XCTFail("Second purchase should NOT succeed because a transaction is already pending.") + case .failure(let error as PigeonError): + XCTAssertEqual(error.code, "storekit_duplicate_product_object") + secondPurchaseExpectation.fulfill() + case .failure(let error): + XCTFail("Unexpected error type: \(error)") + } + } + await fulfillment(of: [secondPurchaseExpectation], timeout: 5) + } } 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 abc167b353bb..61b5cdfd5cb5 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.8+1 +version: 0.4.8+2 environment: sdk: ^3.9.0