diff --git a/packages/in_app_purchase/in_app_purchase/README.md b/packages/in_app_purchase/in_app_purchase/README.md index 28b3c0821cf3..61803e35ebdc 100644 --- a/packages/in_app_purchase/in_app_purchase/README.md +++ b/packages/in_app_purchase/in_app_purchase/README.md @@ -247,6 +247,107 @@ InAppPurchase.instance .buyNonConsumable(purchaseParam: purchaseParam); ``` +### Confirming subscription price changes + +When the price of a subscription is changed the consumer will need to confirm that price change. If the consumer does not +confirm the price change the subscription will not be auto-renewed. By default on both iOS and Android the consumer will +automatically get a popup to confirm the price change, but App developers can override this mechanism and show the popup on a later moment so it doesn't interrupt the critical flow of the App. This works different on the Apple App Store and on the Google Play Store. + +#### Google Play Store (Android) +When the subscription price is raised, the consumer should approve the price change within 7 days. The official +documentation can be found [here](https://support.google.com/googleplay/android-developer/answer/140504?hl=en#zippy=%2Cprice-changes). +When the price is lowered the consumer will automatically receive the lower price and does not have to approve the price change. + +After 7 days the consumer will be notified through email and notifications on Google Play to agree with the new price. App developers have 7 days to explain the consumer that the price is going to change and ask them to accept this change. App developers have to keep track of whether or not the price change is already accepted within the app or in the backend. The [Google Play API](https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptions) can be used to check whether or not the price change is accepted by the consumer by reading the `priceChange` property on a subscription object. + +The `InAppPurchaseAndroidPlatformAddition` can be used to show the price change confirmation flow. The additions contain the function `launchPriceChangeConfirmationFlow` which needs the SKU code of the subscription. + +```dart +//import for InAppPurchaseAndroidPlatformAddition +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +//import for BillingResponse +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; + +if (Platform.isAndroid) { + final InAppPurchaseAndroidPlatformAddition androidAddition = + _inAppPurchase + .getPlatformAddition(); + var priceChangeConfirmationResult = + await androidAddition.launchPriceChangeConfirmationFlow( + sku: 'purchaseId', + ); + if (priceChangeConfirmationResult.responseCode == BillingResponse.ok){ + // TODO acknowledge price change + }else{ + // TODO show error + } +} +``` + +#### Apple App Store (iOS) + +When the price of a subscription is raised iOS will also show a popup in the app. +The StoreKit Payment Queue will notify the app that it wants to show a price change confirmation popup. +By default the queue will get the response that it can continue and show the popup. +However, it is possible to prevent this popup via the InAppPurchaseIosPlatformAddition and show the +popup at a different time, for example after clicking a button. + +To know when the App Store wants to show a popup and prevent this from happening a queue delegate can be registered. +The `InAppPurchaseIosPlatformAddition` contains a `setDelegate(SKPaymentQueueDelegateWrapper? delegate)` function that +can be used to set a delegate or remove one by setting it to `null`. +```dart +//import for InAppPurchaseIosPlatformAddition +import 'package:in_app_purchase_ios/in_app_purchase_ios.dart'; + +Future initStoreInfo() async { + if (Platform.isIOS) { + var iosPlatformAddition = _inAppPurchase + .getPlatformAddition(); + await iosPlatformAddition.setDelegate(ExamplePaymentQueueDelegate()); + } +} + +@override +Future disposeStore() { + if (Platform.isIOS) { + var iosPlatformAddition = _inAppPurchase + .getPlatformAddition(); + await iosPlatformAddition.setDelegate(null); + } +} +``` +The delegate that is set should implement `SKPaymentQueueDelegateWrapper` and handle `shouldContinueTransaction` and +`shouldShowPriceConsent`. When setting `shouldShowPriceConsent` to false the default popup will not be shown and the app +needs to show this later. + +```dart +// import for SKPaymentQueueDelegateWrapper +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; + +class ExamplePaymentQueueDelegate implements SKPaymentQueueDelegateWrapper { + @override + bool shouldContinueTransaction( + SKPaymentTransactionWrapper transaction, SKStorefrontWrapper storefront) { + return true; + } + + @override + bool shouldShowPriceConsent() { + return false; + } +} +``` + +The dialog can be shown by calling `showPriceConsentIfNeeded` on the `InAppPurchaseIosPlatformAddition`. This future +will complete immediately when the dialog is shown. A confirmed transaction will be delivered on the `purchaseStream`. +```dart +if (Platform.isIOS) { + var iapIosPlatformAddition = _inAppPurchase + .getPlatformAddition(); + await iapIosPlatformAddition.showPriceConsentIfNeeded(); +} +``` + ### Accessing platform specific product or purchase properties The function `_inAppPurchase.queryProductDetails(productIds);` provides a `ProductDetailsResponse` with a diff --git a/packages/in_app_purchase/in_app_purchase/example/lib/main.dart b/packages/in_app_purchase/in_app_purchase/example/lib/main.dart index 5429a00125ac..73ecadb3f15d 100644 --- a/packages/in_app_purchase/in_app_purchase/example/lib/main.dart +++ b/packages/in_app_purchase/in_app_purchase/example/lib/main.dart @@ -9,6 +9,8 @@ import 'package:flutter/material.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:in_app_purchase_android/billing_client_wrappers.dart'; import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +import 'package:in_app_purchase_ios/in_app_purchase_ios.dart'; +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; import 'consumable_store.dart'; void main() { @@ -84,6 +86,12 @@ class _MyAppState extends State<_MyApp> { return; } + if (Platform.isIOS) { + var iosPlatformAddition = _inAppPurchase + .getPlatformAddition(); + await iosPlatformAddition.setDelegate(ExamplePaymentQueueDelegate()); + } + ProductDetailsResponse productDetailResponse = await _inAppPurchase.queryProductDetails(_kProductIds.toSet()); if (productDetailResponse.error != null) { @@ -127,6 +135,11 @@ class _MyAppState extends State<_MyApp> { @override void dispose() { + if (Platform.isIOS) { + var iosPlatformAddition = _inAppPurchase + .getPlatformAddition(); + iosPlatformAddition.setDelegate(null); + } _subscription.cancel(); super.dispose(); } @@ -245,7 +258,9 @@ class _MyAppState extends State<_MyApp> { productDetails.description, ), trailing: previousPurchase != null - ? Icon(Icons.check) + ? IconButton( + onPressed: () => confirmPriceChange(context), + icon: Icon(Icons.upgrade)) : TextButton( child: Text(productDetails.price), style: TextButton.styleFrom( @@ -438,6 +453,35 @@ class _MyAppState extends State<_MyApp> { }); } + Future confirmPriceChange(BuildContext context) async { + if (Platform.isAndroid) { + final InAppPurchaseAndroidPlatformAddition androidAddition = + _inAppPurchase + .getPlatformAddition(); + var priceChangeConfirmationResult = + await androidAddition.launchPriceChangeConfirmationFlow( + sku: 'purchaseId', + ); + if (priceChangeConfirmationResult.responseCode == BillingResponse.ok) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Price change accepted'), + )); + } else { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text( + priceChangeConfirmationResult.debugMessage ?? + "Price change failed with code ${priceChangeConfirmationResult.responseCode}", + ), + )); + } + } + if (Platform.isIOS) { + var iapIosPlatformAddition = _inAppPurchase + .getPlatformAddition(); + await iapIosPlatformAddition.showPriceConsentIfNeeded(); + } + } + GooglePlayPurchaseDetails? _getOldSubscription( ProductDetails productDetails, Map purchases) { // This is just to demonstrate a subscription upgrade or downgrade. @@ -460,3 +504,21 @@ class _MyAppState extends State<_MyApp> { return oldSubscription; } } + +/// Example implementation of the +/// [`SKPaymentQueueDelegate`](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate?language=objc). +/// +/// The payment queue delegate can be implementated to provide information +/// needed to complete transactions. +class ExamplePaymentQueueDelegate implements SKPaymentQueueDelegateWrapper { + @override + bool shouldContinueTransaction( + SKPaymentTransactionWrapper transaction, SKStorefrontWrapper storefront) { + return true; + } + + @override + bool shouldShowPriceConsent() { + return false; + } +} diff --git a/packages/in_app_purchase/in_app_purchase/pubspec.yaml b/packages/in_app_purchase/in_app_purchase/pubspec.yaml index b589c24d3677..554a07b0bd30 100644 --- a/packages/in_app_purchase/in_app_purchase/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase/pubspec.yaml @@ -20,8 +20,8 @@ dependencies: flutter: sdk: flutter in_app_purchase_platform_interface: ^1.0.0 - in_app_purchase_android: ^0.1.0 - in_app_purchase_ios: ^0.1.0 + in_app_purchase_android: ^0.1.4 + in_app_purchase_ios: ^0.1.1 dev_dependencies: flutter_driver: