diff --git a/AUTHORS b/AUTHORS index 1f2b9cba2f16..dbf9d190931b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -62,3 +62,4 @@ Juan Alvarez Aleksandr Yurkovskiy Anton Borries Alex Li +Rahul Raj <64.rahulraj@gmail.com> diff --git a/packages/in_app_purchase/CHANGELOG.md b/packages/in_app_purchase/CHANGELOG.md index 79f64d5bda53..0dbc2427ccd6 100644 --- a/packages/in_app_purchase/CHANGELOG.md +++ b/packages/in_app_purchase/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.4.1 + +* Support InApp subscription upgrade/downgrade. + ## 0.4.0 * Migrate to nullsafety. diff --git a/packages/in_app_purchase/README.md b/packages/in_app_purchase/README.md index 431f2810c165..321864b2a233 100644 --- a/packages/in_app_purchase/README.md +++ b/packages/in_app_purchase/README.md @@ -178,6 +178,30 @@ and `AppStore` that the purchase has been finished. WARNING! Failure to call `InAppPurchaseConnection.completePurchase` and get a successful response within 3 days of the purchase will result a refund. +### Upgrading or Downgrading an existing InApp Subscription + +In order to upgrade/downgrade an existing InApp subscription on `PlayStore`, +you need to provide an instance of `ChangeSubscriptionParam` with the old +`PurchaseDetails` that the user needs to migrate from, and an optional `ProrationMode` +with the `PurchaseParam` object while calling `InAppPurchaseConnection.buyNonConsumable`. +`AppStore` does not require this since they provides a subscription grouping mechanism. +Each subscription you offer must be assigned to a subscription group. +So the developers can group related subscriptions together to prevents users from +accidentally purchasing multiple subscriptions. +Please refer to the 'Creating a Subscription Group' sections of [Apple's subscription guide](https://developer.apple.com/app-store/subscriptions/) + + +```dart +final PurchaseDetails oldPurchaseDetails = ...; +PurchaseParam purchaseParam = PurchaseParam( + productDetails: productDetails, + changeSubscriptionParam: ChangeSubscriptionParam( + oldPurchaseDetails: oldPurchaseDetails, + prorationMode: ProrationMode.immediateWithTimeProration)); +InAppPurchaseConnection.instance + .buyNonConsumable(purchaseParam: purchaseParam); +``` + ## Development This plugin uses diff --git a/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java b/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java index f1e715e239a2..58d077673a03 100644 --- a/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java +++ b/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java @@ -20,6 +20,7 @@ import com.android.billingclient.api.BillingClient; import com.android.billingclient.api.BillingClientStateListener; import com.android.billingclient.api.BillingFlowParams; +import com.android.billingclient.api.BillingFlowParams.ProrationMode; import com.android.billingclient.api.BillingResult; import com.android.billingclient.api.ConsumeParams; import com.android.billingclient.api.ConsumeResponseListener; @@ -39,6 +40,8 @@ class MethodCallHandlerImpl implements MethodChannel.MethodCallHandler, Application.ActivityLifecycleCallbacks { private static final String TAG = "InAppPurchasePlugin"; + private static final String LOAD_SKU_DOC_URL = + "https://github.com/flutter/plugins/blob/master/packages/in_app_purchase/README.md#loading-products-for-sale"; @Nullable private BillingClient billingClient; private final BillingClientFactory billingClientFactory; @@ -120,7 +123,13 @@ public void onMethodCall(MethodCall call, MethodChannel.Result result) { break; case InAppPurchasePlugin.MethodNames.LAUNCH_BILLING_FLOW: launchBillingFlow( - (String) call.argument("sku"), (String) call.argument("accountId"), result); + (String) call.argument("sku"), + (String) call.argument("accountId"), + (String) call.argument("oldSku"), + call.hasArgument("prorationMode") + ? (int) call.argument("prorationMode") + : ProrationMode.UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY, + result); break; case InAppPurchasePlugin.MethodNames.QUERY_PURCHASES: queryPurchases((String) call.argument("skuType"), result); @@ -189,7 +198,11 @@ public void onSkuDetailsResponse( } private void launchBillingFlow( - String sku, @Nullable String accountId, MethodChannel.Result result) { + String sku, + @Nullable String accountId, + @Nullable String oldSku, + int prorationMode, + MethodChannel.Result result) { if (billingClientError(result)) { return; } @@ -198,7 +211,26 @@ private void launchBillingFlow( if (skuDetails == null) { result.error( "NOT_FOUND", - "Details for sku " + sku + " are not available. Has this ID already been fetched?", + String.format( + "Details for sku %s are not available. It might because skus were not fetched prior to the call. Please fetch the skus first. An example of how to fetch the skus could be found here: %s", + sku, LOAD_SKU_DOC_URL), + null); + return; + } + + if (oldSku == null + && prorationMode != ProrationMode.UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY) { + result.error( + "IN_APP_PURCHASE_REQUIRE_OLD_SKU", + "launchBillingFlow failed because oldSku is null. You must provide a valid oldSku in order to use a proration mode.", + null); + return; + } else if (oldSku != null && !cachedSkus.containsKey(oldSku)) { + result.error( + "IN_APP_PURCHASE_INVALID_OLD_SKU", + String.format( + "Details for sku %s are not available. It might because skus were not fetched prior to the call. Please fetch the skus first. An example of how to fetch the skus could be found here: %s", + oldSku, LOAD_SKU_DOC_URL), null); return; } @@ -218,6 +250,12 @@ private void launchBillingFlow( if (accountId != null && !accountId.isEmpty()) { paramsBuilder.setAccountId(accountId); } + if (oldSku != null && !oldSku.isEmpty()) { + paramsBuilder.setOldSku(oldSku); + } + // The proration mode value has to match one of the following declared in + // https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProrationMode + paramsBuilder.setReplaceSkusProrationMode(prorationMode); result.success( Translator.fromBillingResult( billingClient.launchBillingFlow(activity, paramsBuilder.build()))); @@ -252,7 +290,8 @@ private void queryPurchases(String skuType, MethodChannel.Result result) { return; } - // Like in our connect call, consider the billing client responding a "success" here regardless of status code. + // Like in our connect call, consider the billing client responding a "success" here regardless + // of status code. result.success(fromPurchasesResult(billingClient.queryPurchases(skuType))); } @@ -295,7 +334,8 @@ public void onBillingSetupFinished(BillingResult billingResult) { return; } alreadyFinished = true; - // Consider the fact that we've finished a success, leave it to the Dart side to validate the responseCode. + // Consider the fact that we've finished a success, leave it to the Dart side to + // validate the responseCode. result.success(Translator.fromBillingResult(billingResult)); } diff --git a/packages/in_app_purchase/example/README.md b/packages/in_app_purchase/example/README.md index 9fcad23d19ae..6dd5b38d7003 100644 --- a/packages/in_app_purchase/example/README.md +++ b/packages/in_app_purchase/example/README.md @@ -30,7 +30,8 @@ below. - `consumable`: A managed product. - `upgrade`: A managed product. - - `subscription`: A subscription. + - `subscription_silver`: A lower level subscription. + - `subscription_gold`: A higher level subscription. Make sure that all of the products are set to `ACTIVE`. diff --git a/packages/in_app_purchase/example/android/app/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java b/packages/in_app_purchase/example/android/app/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java index c6a9b4114a75..cc7bc4a9b9b1 100644 --- a/packages/in_app_purchase/example/android/app/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java +++ b/packages/in_app_purchase/example/android/app/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java @@ -18,6 +18,7 @@ import static io.flutter.plugins.inapppurchase.Translator.fromSkuDetailsList; import static java.util.Arrays.asList; import static java.util.Collections.singletonList; +import static java.util.Collections.unmodifiableList; import static java.util.stream.Collectors.toList; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -261,7 +262,7 @@ public void querySkuDetailsAsync_clientDisconnected() { } @Test - public void launchBillingFlow_ok_nullAccountId() { + public void launchBillingFlow_ok_null_AccountId() { // Fetch the sku details first and then prepare the launch billing flow call String skuId = "foo"; queryForSkus(singletonList(skuId)); @@ -292,6 +293,40 @@ public void launchBillingFlow_ok_nullAccountId() { verify(result, times(1)).success(fromBillingResult(billingResult)); } + @Test + public void launchBillingFlow_ok_null_OldSku() { + // Fetch the sku details first and then prepare the launch billing flow call + String skuId = "foo"; + String accountId = "account"; + queryForSkus(singletonList(skuId)); + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + arguments.put("accountId", accountId); + arguments.put("oldSku", null); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + + // Launch the billing flow + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult); + methodChannelHandler.onMethodCall(launchCall, result); + + // Verify we pass the arguments to the billing flow + ArgumentCaptor billingFlowParamsCaptor = + ArgumentCaptor.forClass(BillingFlowParams.class); + verify(mockBillingClient).launchBillingFlow(any(), billingFlowParamsCaptor.capture()); + BillingFlowParams params = billingFlowParamsCaptor.getValue(); + assertEquals(params.getSku(), skuId); + assertEquals(params.getAccountId(), accountId); + assertNull(params.getOldSku()); + // Verify we pass the response code to result + verify(result, never()).error(any(), any(), any()); + verify(result, times(1)).success(fromBillingResult(billingResult)); + } + @Test public void launchBillingFlow_ok_null_Activity() { methodChannelHandler.setActivity(null); @@ -311,6 +346,42 @@ public void launchBillingFlow_ok_null_Activity() { verify(result, never()).success(any()); } + @Test + public void launchBillingFlow_ok_oldSku() { + // Fetch the sku details first and query the method call + String skuId = "foo"; + String accountId = "account"; + String oldSkuId = "oldFoo"; + queryForSkus(unmodifiableList(asList(skuId, oldSkuId))); + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + arguments.put("accountId", accountId); + arguments.put("oldSku", oldSkuId); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + + // Launch the billing flow + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult); + methodChannelHandler.onMethodCall(launchCall, result); + + // Verify we pass the arguments to the billing flow + ArgumentCaptor billingFlowParamsCaptor = + ArgumentCaptor.forClass(BillingFlowParams.class); + verify(mockBillingClient).launchBillingFlow(any(), billingFlowParamsCaptor.capture()); + BillingFlowParams params = billingFlowParamsCaptor.getValue(); + assertEquals(params.getSku(), skuId); + assertEquals(params.getAccountId(), accountId); + assertEquals(params.getOldSku(), oldSkuId); + + // Verify we pass the response code to result + verify(result, never()).error(any(), any(), any()); + verify(result, times(1)).success(fromBillingResult(billingResult)); + } + @Test public void launchBillingFlow_ok_AccountId() { // Fetch the sku details first and query the method call @@ -344,6 +415,79 @@ public void launchBillingFlow_ok_AccountId() { verify(result, times(1)).success(fromBillingResult(billingResult)); } + @Test + public void launchBillingFlow_ok_Proration() { + // Fetch the sku details first and query the method call + String skuId = "foo"; + String oldSkuId = "oldFoo"; + String accountId = "account"; + int prorationMode = BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE; + queryForSkus(unmodifiableList(asList(skuId, oldSkuId))); + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + arguments.put("accountId", accountId); + arguments.put("oldSku", oldSkuId); + arguments.put("prorationMode", prorationMode); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + + // Launch the billing flow + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult); + methodChannelHandler.onMethodCall(launchCall, result); + + // Verify we pass the arguments to the billing flow + ArgumentCaptor billingFlowParamsCaptor = + ArgumentCaptor.forClass(BillingFlowParams.class); + verify(mockBillingClient).launchBillingFlow(any(), billingFlowParamsCaptor.capture()); + BillingFlowParams params = billingFlowParamsCaptor.getValue(); + assertEquals(params.getSku(), skuId); + assertEquals(params.getAccountId(), accountId); + assertEquals(params.getOldSku(), oldSkuId); + assertEquals(params.getReplaceSkusProrationMode(), prorationMode); + + // Verify we pass the response code to result + verify(result, never()).error(any(), any(), any()); + verify(result, times(1)).success(fromBillingResult(billingResult)); + } + + @Test + public void launchBillingFlow_ok_Proration_with_null_OldSku() { + // Fetch the sku details first and query the method call + String skuId = "foo"; + String accountId = "account"; + String queryOldSkuId = "oldFoo"; + String oldSkuId = null; + int prorationMode = BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE; + queryForSkus(unmodifiableList(asList(skuId, queryOldSkuId))); + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + arguments.put("accountId", accountId); + arguments.put("oldSku", oldSkuId); + arguments.put("prorationMode", prorationMode); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + + // Launch the billing flow + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult); + methodChannelHandler.onMethodCall(launchCall, result); + + // Assert that we sent an error back. + verify(result) + .error( + contains("IN_APP_PURCHASE_REQUIRE_OLD_SKU"), + contains("launchBillingFlow failed because oldSku is null"), + any()); + verify(result, never()).success(any()); + } + @Test public void launchBillingFlow_clientDisconnected() { // Prepare the launch call after disconnecting the client @@ -381,6 +525,27 @@ public void launchBillingFlow_skuNotFound() { verify(result, never()).success(any()); } + @Test + public void launchBillingFlow_oldSkuNotFound() { + // Try to launch the billing flow for a random sku ID + establishConnectedBillingClient(null, null); + String skuId = "foo"; + String accountId = "account"; + String oldSkuId = "oldSku"; + queryForSkus(singletonList(skuId)); + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + arguments.put("accountId", accountId); + arguments.put("oldSku", oldSkuId); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + + methodChannelHandler.onMethodCall(launchCall, result); + + // Assert that we sent an error back. + verify(result).error(contains("IN_APP_PURCHASE_INVALID_OLD_SKU"), contains(oldSkuId), any()); + verify(result, never()).success(any()); + } + @Test public void queryPurchases() { establishConnectedBillingClient(null, null); diff --git a/packages/in_app_purchase/example/lib/main.dart b/packages/in_app_purchase/example/lib/main.dart index 82cd509b30be..c9f0bb6ece25 100644 --- a/packages/in_app_purchase/example/lib/main.dart +++ b/packages/in_app_purchase/example/lib/main.dart @@ -19,10 +19,14 @@ void main() { const bool _kAutoConsume = true; const String _kConsumableId = 'consumable'; +const String _kUpgradeId = 'upgrade'; +const String _kSilverSubscriptionId = 'subscription_silver'; +const String _kGoldSubscriptionId = 'subscription_gold'; const List _kProductIds = [ _kConsumableId, - 'upgrade', - 'subscription' + _kUpgradeId, + _kSilverSubscriptionId, + _kGoldSubscriptionId, ]; class _MyApp extends StatefulWidget { @@ -252,9 +256,22 @@ class _MyAppState extends State<_MyApp> { primary: Colors.white, ), onPressed: () { + // NOTE: If you are making a subscription purchase/upgrade/downgrade, we recommend you to + // verify the latest status of you your subscription by using server side receipt validation + // and update the UI accordingly. The subscription purchase status shown + // inside the app may not be accurate. + final oldSubscription = + _getOldSubscription(productDetails, purchases); PurchaseParam purchaseParam = PurchaseParam( productDetails: productDetails, - applicationUserName: null); + applicationUserName: null, + changeSubscriptionParam: Platform.isAndroid && + oldSubscription != null + ? ChangeSubscriptionParam( + oldPurchaseDetails: oldSubscription, + prorationMode: + ProrationMode.immediateWithTimeProration) + : null); if (productDetails.id == _kConsumableId) { _connection.buyConsumable( purchaseParam: purchaseParam, @@ -387,4 +404,24 @@ class _MyAppState extends State<_MyApp> { } }); } + + PurchaseDetails? _getOldSubscription( + ProductDetails productDetails, Map purchases) { + // This is just to demonstrate a subscription upgrade or downgrade. + // This method assumes that you have only 2 subscriptions under a group, 'subscription_silver' & 'subscription_gold'. + // The 'subscription_silver' subscription can be upgraded to 'subscription_gold' and + // the 'subscription_gold' subscription can be downgraded to 'subscription_silver'. + // Please remember to replace the logic of finding the old subscription Id as per your app. + // The old subscription is only required on Android since Apple handles this internally + // by using the subscription group feature in iTunesConnect. + PurchaseDetails? oldSubscription; + if (productDetails.id == _kSilverSubscriptionId && + purchases[_kGoldSubscriptionId] != null) { + oldSubscription = purchases[_kGoldSubscriptionId]; + } else if (productDetails.id == _kGoldSubscriptionId && + purchases[_kSilverSubscriptionId] != null) { + oldSubscription = purchases[_kSilverSubscriptionId]; + } + return oldSubscription; + } } diff --git a/packages/in_app_purchase/lib/src/billing_client_wrappers/billing_client_wrapper.dart b/packages/in_app_purchase/lib/src/billing_client_wrappers/billing_client_wrapper.dart index 9f96c05e15f9..a0ba91556094 100644 --- a/packages/in_app_purchase/lib/src/billing_client_wrappers/billing_client_wrapper.dart +++ b/packages/in_app_purchase/lib/src/billing_client_wrappers/billing_client_wrapper.dart @@ -173,12 +173,25 @@ class BillingClient { /// skuDetails](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder.html#setskudetails) /// and [the given /// accountId](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder.html#setAccountId(java.lang.String)). + /// + /// When this method is called to purchase a subscription, an optional `oldSku` + /// can be passed in. This will tell Google Play that rather than purchasing a new subscription, + /// the user needs to upgrade/downgrade the existing subscription. + /// The [oldSku](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setoldsku) is the SKU id that the user is upgrading or downgrading from. + /// The [prorationMode](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setreplaceskusprorationmode) is the mode of proration during subscription upgrade/downgrade. + /// This value will only be effective if the `oldSku` is also set. Future launchBillingFlow( - {required String sku, String? accountId}) async { + {required String sku, + String? accountId, + String? oldSku, + ProrationMode? prorationMode}) async { assert(sku != null); final Map arguments = { 'sku': sku, 'accountId': accountId, + 'oldSku': oldSku, + 'prorationMode': ProrationModeConverter().toJson(prorationMode ?? + ProrationMode.unknownSubscriptionUpgradeDowngradePolicy) }; return BillingResultWrapper.fromJson( (await channel.invokeMapMethod( @@ -390,3 +403,43 @@ enum SkuType { @JsonValue('subs') subs, } + +/// Enum representing the proration mode. +/// +/// When upgrading or downgrading a subscription, set this mode to provide details +/// about the proration that will be applied when the subscription changes. +/// +/// Wraps [`BillingFlowParams.ProrationMode`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProrationMode) +/// See the linked documentation for an explanation of the different constants. +enum ProrationMode { +// WARNING: Changes to this class need to be reflected in our generated code. +// Run `flutter packages pub run build_runner watch` to rebuild and watch for +// further changes. + + /// Unknown upgrade or downgrade policy. + @JsonValue(0) + unknownSubscriptionUpgradeDowngradePolicy, + + /// Replacement takes effect immediately, and the remaining time will be prorated and credited to the user. + /// + /// This is the current default behavior. + @JsonValue(1) + immediateWithTimeProration, + + /// Replacement takes effect immediately, and the billing cycle remains the same. + /// + /// The price for the remaining period will be charged. + /// This option is only available for subscription upgrade. + @JsonValue(2) + immediateAndChargeProratedPrice, + + /// Replacement takes effect immediately, and the new price will be charged on next recurrence time. + /// + /// The billing cycle stays the same. + @JsonValue(3) + immediateWithoutProration, + + /// Replacement takes effect when the old plan expires, and the new price will be charged at the same time. + @JsonValue(4) + deferred, +} diff --git a/packages/in_app_purchase/lib/src/billing_client_wrappers/enum_converters.dart b/packages/in_app_purchase/lib/src/billing_client_wrappers/enum_converters.dart index 30828d8882a7..469d71b63637 100644 --- a/packages/in_app_purchase/lib/src/billing_client_wrappers/enum_converters.dart +++ b/packages/in_app_purchase/lib/src/billing_client_wrappers/enum_converters.dart @@ -50,12 +50,34 @@ class SkuTypeConverter implements JsonConverter { String toJson(SkuType object) => _$SkuTypeEnumMap[object]!; } +/// Serializer for [ProrationMode]. +/// +/// Use these in `@JsonSerializable()` classes by annotating them with +/// `@ProrationModeConverter()`. +class ProrationModeConverter implements JsonConverter { + /// Default const constructor. + const ProrationModeConverter(); + + @override + ProrationMode fromJson(int? json) { + if (json == null) { + return ProrationMode.unknownSubscriptionUpgradeDowngradePolicy; + } + return _$enumDecode( + _$ProrationModeEnumMap.cast(), json); + } + + @override + int toJson(ProrationMode object) => _$ProrationModeEnumMap[object]!; +} + // Define a class so we generate serializer helper methods for the enums @JsonSerializable() class _SerializedEnums { late BillingResponse response; late SkuType type; late PurchaseStateWrapper purchaseState; + late ProrationMode prorationMode; } /// Serializer for [PurchaseStateWrapper]. diff --git a/packages/in_app_purchase/lib/src/billing_client_wrappers/enum_converters.g.dart b/packages/in_app_purchase/lib/src/billing_client_wrappers/enum_converters.g.dart index 5d59dd8888b7..4186a2a24252 100644 --- a/packages/in_app_purchase/lib/src/billing_client_wrappers/enum_converters.g.dart +++ b/packages/in_app_purchase/lib/src/billing_client_wrappers/enum_converters.g.dart @@ -11,7 +11,9 @@ _SerializedEnums _$_SerializedEnumsFromJson(Map json) { ..response = _$enumDecode(_$BillingResponseEnumMap, json['response']) ..type = _$enumDecode(_$SkuTypeEnumMap, json['type']) ..purchaseState = - _$enumDecode(_$PurchaseStateWrapperEnumMap, json['purchaseState']); + _$enumDecode(_$PurchaseStateWrapperEnumMap, json['purchaseState']) + ..prorationMode = + _$enumDecode(_$ProrationModeEnumMap, json['prorationMode']); } Map _$_SerializedEnumsToJson(_SerializedEnums instance) => @@ -19,6 +21,7 @@ Map _$_SerializedEnumsToJson(_SerializedEnums instance) => 'response': _$BillingResponseEnumMap[instance.response], 'type': _$SkuTypeEnumMap[instance.type], 'purchaseState': _$PurchaseStateWrapperEnumMap[instance.purchaseState], + 'prorationMode': _$ProrationModeEnumMap[instance.prorationMode], }; K _$enumDecode( @@ -72,3 +75,11 @@ const _$PurchaseStateWrapperEnumMap = { PurchaseStateWrapper.purchased: 1, PurchaseStateWrapper.pending: 2, }; + +const _$ProrationModeEnumMap = { + ProrationMode.unknownSubscriptionUpgradeDowngradePolicy: 0, + ProrationMode.immediateWithTimeProration: 1, + ProrationMode.immediateAndChargeProratedPrice: 2, + ProrationMode.immediateWithoutProration: 3, + ProrationMode.deferred: 4, +}; diff --git a/packages/in_app_purchase/lib/src/in_app_purchase/app_store_connection.dart b/packages/in_app_purchase/lib/src/in_app_purchase/app_store_connection.dart index 50560a666a40..d4601fd809db 100644 --- a/packages/in_app_purchase/lib/src/in_app_purchase/app_store_connection.dart +++ b/packages/in_app_purchase/lib/src/in_app_purchase/app_store_connection.dart @@ -56,6 +56,15 @@ class AppStoreConnection implements InAppPurchaseConnection { @override Future buyNonConsumable({required PurchaseParam purchaseParam}) async { + assert( + purchaseParam.changeSubscriptionParam == null, + "`purchaseParam.changeSubscriptionParam` must be null. It is not supported on iOS " + "as Apple provides a subscription grouping mechanism. " + "Each subscription you offer must be assigned to a subscription group. " + "So the developers can group related subscriptions together to prevents users " + "from accidentally purchasing multiple subscriptions. " + "Please refer to the 'Creating a Subscription Group' sections of " + "Apple's subscription guide (https://developer.apple.com/app-store/subscriptions/)"); await _skPaymentQueueWrapper.addPayment(SKPaymentWrapper( productIdentifier: purchaseParam.productDetails.id, quantity: 1, diff --git a/packages/in_app_purchase/lib/src/in_app_purchase/google_play_connection.dart b/packages/in_app_purchase/lib/src/in_app_purchase/google_play_connection.dart index ef0b7d2efa59..1a47f3ebd095 100644 --- a/packages/in_app_purchase/lib/src/in_app_purchase/google_play_connection.dart +++ b/packages/in_app_purchase/lib/src/in_app_purchase/google_play_connection.dart @@ -63,7 +63,11 @@ class GooglePlayConnection BillingResultWrapper billingResultWrapper = await billingClient.launchBillingFlow( sku: purchaseParam.productDetails.id, - accountId: purchaseParam.applicationUserName); + accountId: purchaseParam.applicationUserName, + oldSku: purchaseParam + .changeSubscriptionParam?.oldPurchaseDetails.productID, + prorationMode: + purchaseParam.changeSubscriptionParam?.prorationMode); return billingResultWrapper.responseCode == BillingResponse.ok; } diff --git a/packages/in_app_purchase/lib/src/in_app_purchase/purchase_details.dart b/packages/in_app_purchase/lib/src/in_app_purchase/purchase_details.dart index c211d2a4cdb8..b4a509055f14 100644 --- a/packages/in_app_purchase/lib/src/in_app_purchase/purchase_details.dart +++ b/packages/in_app_purchase/lib/src/in_app_purchase/purchase_details.dart @@ -90,7 +90,8 @@ class PurchaseParam { {required this.productDetails, this.applicationUserName, this.sandboxTesting = false, - this.simulatesAskToBuyInSandbox = false}); + this.simulatesAskToBuyInSandbox = false, + this.changeSubscriptionParam}); /// The product to create payment for. /// @@ -117,6 +118,38 @@ class PurchaseParam { /// /// See also [SKPaymentWrapper.simulatesAskToBuyInSandbox]. final bool simulatesAskToBuyInSandbox; + + /// The 'changeSubscriptionParam' is only available on Android, for upgrading or + /// downgrading an existing subscription. + /// + /// This does not require on iOS since Apple provides a way to group related subscriptions + /// together in iTunesConnect. So when a subscription upgrade or downgrade is requested, + /// Apple finds the old subscription details from the group and handle it automatically. + final ChangeSubscriptionParam? changeSubscriptionParam; +} + +/// This parameter object which is only applicable on Android for upgrading or downgrading an existing subscription. +/// +/// This does not require on iOS since iTunesConnect provides a subscription grouping mechanism. +/// Each subscription you offer must be assigned to a subscription group. +/// So the developers can group related subscriptions together to prevent users from +/// accidentally purchasing multiple subscriptions. +/// +/// Please refer to the 'Creating a Subscription Group' sections of [Apple's subscription guide](https://developer.apple.com/app-store/subscriptions/) +class ChangeSubscriptionParam { + /// Creates a new change subscription param object with given data + ChangeSubscriptionParam( + {required this.oldPurchaseDetails, this.prorationMode}); + + /// The purchase object of the existing subscription that the user needs to + /// upgrade/downgrade from. + final PurchaseDetails oldPurchaseDetails; + + /// The proration mode. + /// + /// This is an optional parameter that indicates how to handle the existing + /// subscription when the new subscription comes into effect. + final ProrationMode? prorationMode; } /// Represents the transaction details of a purchase. diff --git a/packages/in_app_purchase/pubspec.yaml b/packages/in_app_purchase/pubspec.yaml index f847a81291be..6175e8cbf1dc 100644 --- a/packages/in_app_purchase/pubspec.yaml +++ b/packages/in_app_purchase/pubspec.yaml @@ -1,7 +1,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. homepage: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase -version: 0.4.0 +version: 0.4.1 dependencies: flutter: diff --git a/packages/in_app_purchase/test/billing_client_wrappers/billing_client_wrapper_test.dart b/packages/in_app_purchase/test/billing_client_wrappers/billing_client_wrapper_test.dart index d415007284c8..3aa62ddd96a1 100644 --- a/packages/in_app_purchase/test/billing_client_wrappers/billing_client_wrapper_test.dart +++ b/packages/in_app_purchase/test/billing_client_wrappers/billing_client_wrapper_test.dart @@ -207,6 +207,64 @@ void main() { expect(arguments['accountId'], equals(accountId)); }); + test( + 'serializes and deserializes data on change subscription without proration', + () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = "hashedAccountId"; + + expect( + await billingClient.launchBillingFlow( + sku: skuDetails.sku, + accountId: accountId, + oldSku: dummyOldPurchase.sku), + equals(expectedBillingResult)); + Map arguments = + stubPlatform.previousCallMatching(launchMethodName).arguments; + expect(arguments['sku'], equals(skuDetails.sku)); + expect(arguments['accountId'], equals(accountId)); + expect(arguments['oldSku'], equals(dummyOldPurchase.sku)); + }); + + test( + 'serializes and deserializes data on change subscription with proration', + () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = "hashedAccountId"; + final prorationMode = ProrationMode.immediateAndChargeProratedPrice; + + expect( + await billingClient.launchBillingFlow( + sku: skuDetails.sku, + accountId: accountId, + oldSku: dummyOldPurchase.sku, + prorationMode: prorationMode), + equals(expectedBillingResult)); + Map arguments = + stubPlatform.previousCallMatching(launchMethodName).arguments; + expect(arguments['sku'], equals(skuDetails.sku)); + expect(arguments['accountId'], equals(accountId)); + expect(arguments['oldSku'], equals(dummyOldPurchase.sku)); + expect(arguments['prorationMode'], + ProrationModeConverter().toJson(prorationMode)); + }); + test('handles null accountId', () async { const String debugMessage = 'dummy message'; final BillingResponse responseCode = BillingResponse.ok; diff --git a/packages/in_app_purchase/test/billing_client_wrappers/purchase_wrapper_test.dart b/packages/in_app_purchase/test/billing_client_wrappers/purchase_wrapper_test.dart index 7f3de2742603..df5b8f5bde22 100644 --- a/packages/in_app_purchase/test/billing_client_wrappers/purchase_wrapper_test.dart +++ b/packages/in_app_purchase/test/billing_client_wrappers/purchase_wrapper_test.dart @@ -46,6 +46,20 @@ final PurchaseHistoryRecordWrapper dummyPurchaseHistoryRecord = developerPayload: 'dummy payload', ); +final PurchaseWrapper dummyOldPurchase = PurchaseWrapper( + orderId: 'oldOrderId', + packageName: 'oldPackageName', + purchaseTime: 0, + signature: 'oldSignature', + sku: 'oldSku', + purchaseToken: 'oldPurchaseToken', + isAutoRenewing: false, + originalJson: '', + developerPayload: 'old dummy payload', + isAcknowledged: true, + purchaseState: PurchaseStateWrapper.purchased, +); + void main() { group('PurchaseWrapper', () { test('converts from map', () {