diff --git a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md index 08bebbb568d7..3430080cb1ba 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.38/Dart 3.10. * 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 5d57ad2db40a..ec2acd810b74 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.10.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 ab6aa348efdd..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.38/Dart 3.10. +* 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..855c8b344d3a 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,16 @@ abstract class InAppPurchasePlatform extends PlatformInterface { /// For convenience, [PurchaseDetails.pendingCompletePurchase] indicates if a /// purchase is pending for completion. /// + /// > [!WARNING] + /// > On iOS/macOS, 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. + /// > On Android, If you do not call [completePurchase] for a transaction on Android, Google Play + /// > will automatically refund and revoke the purchase after 3 days. + /// /// 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 4980c9baf954..6784e5c70d3c 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.10.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 848fbe16b185..20ae28ac9cfc 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,8 @@ +## 0.4.10 + +* 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.9 * Add support for offer codes in StoreKit 2. @@ -6,6 +11,7 @@ ## 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 64259aad12df..38e5f026615b 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/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift b/packages/in_app_purchase/in_app_purchase_storekit/example/shared/RunnerTests/InAppPurchaseStoreKit2PluginTests.swift index 6248937427a7..e1d3390282f4 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,6 +547,33 @@ 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) + } @available(iOS 16.0, macOS 15.0, *) func testRedeemCodeSheetFailsGracefullyWhenNoWindow() { let expectation = self.expectation( 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 48a826906b27..4a3746860f68 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.9 +version: 0.4.10 environment: sdk: ^3.10.0