[in_app_purchase] Add support for InApp subscription upgrade/downgrade (#2822)

diff --git a/AUTHORS b/AUTHORS
index 1f2b9cb..dbf9d19 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -62,3 +62,4 @@
 Aleksandr Yurkovskiy <sanekyy@gmail.com>
 Anton Borries <mail@antonborri.es>
 Alex Li <google@alexv525.com>
+Rahul Raj <64.rahulraj@gmail.com>
diff --git a/packages/in_app_purchase/CHANGELOG.md b/packages/in_app_purchase/CHANGELOG.md
index 79f64d5..0dbc242 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 431f281..321864b 100644
--- a/packages/in_app_purchase/README.md
+++ b/packages/in_app_purchase/README.md
@@ -178,6 +178,30 @@
 
 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 f1e715e..58d0776 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 @@
     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 @@
         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 @@
   }
 
   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 @@
     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 @@
     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 @@
       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 @@
               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 9fcad23..6dd5b38 100644
--- a/packages/in_app_purchase/example/README.md
+++ b/packages/in_app_purchase/example/README.md
@@ -30,7 +30,8 @@
 
    - `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 c6a9b41..cc7bc4a 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 @@
   }
 
   @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));
@@ -293,6 +294,40 @@
   }
 
   @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<String, Object> 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<BillingFlowParams> 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);
 
@@ -312,6 +347,42 @@
   }
 
   @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<String, Object> 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<BillingFlowParams> 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
     String skuId = "foo";
@@ -345,6 +416,79 @@
   }
 
   @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<String, Object> 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<BillingFlowParams> 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<String, Object> 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
     MethodCall disconnectCall = new MethodCall(END_CONNECTION, null);
@@ -382,6 +526,27 @@
   }
 
   @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<String, Object> 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);
     PurchasesResult purchasesResult = mock(PurchasesResult.class);
diff --git a/packages/in_app_purchase/example/lib/main.dart b/packages/in_app_purchase/example/lib/main.dart
index 82cd509..c9f0bb6 100644
--- a/packages/in_app_purchase/example/lib/main.dart
+++ b/packages/in_app_purchase/example/lib/main.dart
@@ -19,10 +19,14 @@
 const bool _kAutoConsume = true;
 
 const String _kConsumableId = 'consumable';
+const String _kUpgradeId = 'upgrade';
+const String _kSilverSubscriptionId = 'subscription_silver';
+const String _kGoldSubscriptionId = 'subscription_gold';
 const List<String> _kProductIds = <String>[
   _kConsumableId,
-  'upgrade',
-  'subscription'
+  _kUpgradeId,
+  _kSilverSubscriptionId,
+  _kGoldSubscriptionId,
 ];
 
 class _MyApp extends StatefulWidget {
@@ -252,9 +256,22 @@
                       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 @@
       }
     });
   }
+
+  PurchaseDetails? _getOldSubscription(
+      ProductDetails productDetails, Map<String, PurchaseDetails> 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 9f96c05..a0ba915 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 @@
   /// 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<BillingResultWrapper> launchBillingFlow(
-      {required String sku, String? accountId}) async {
+      {required String sku,
+      String? accountId,
+      String? oldSku,
+      ProrationMode? prorationMode}) async {
     assert(sku != null);
     final Map<String, dynamic> arguments = <String, dynamic>{
       'sku': sku,
       'accountId': accountId,
+      'oldSku': oldSku,
+      'prorationMode': ProrationModeConverter().toJson(prorationMode ??
+          ProrationMode.unknownSubscriptionUpgradeDowngradePolicy)
     };
     return BillingResultWrapper.fromJson(
         (await channel.invokeMapMethod<String, dynamic>(
@@ -390,3 +403,43 @@
   @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 30828d8..469d71b 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 @@
   String toJson(SkuType object) => _$SkuTypeEnumMap[object]!;
 }
 
+/// Serializer for [ProrationMode].
+///
+/// Use these in `@JsonSerializable()` classes by annotating them with
+/// `@ProrationModeConverter()`.
+class ProrationModeConverter implements JsonConverter<ProrationMode, int?> {
+  /// Default const constructor.
+  const ProrationModeConverter();
+
+  @override
+  ProrationMode fromJson(int? json) {
+    if (json == null) {
+      return ProrationMode.unknownSubscriptionUpgradeDowngradePolicy;
+    }
+    return _$enumDecode<ProrationMode, dynamic>(
+        _$ProrationModeEnumMap.cast<ProrationMode, dynamic>(), 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 5d59dd8..4186a2a 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 @@
     ..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<String, dynamic> _$_SerializedEnumsToJson(_SerializedEnums instance) =>
@@ -19,6 +21,7 @@
       'response': _$BillingResponseEnumMap[instance.response],
       'type': _$SkuTypeEnumMap[instance.type],
       'purchaseState': _$PurchaseStateWrapperEnumMap[instance.purchaseState],
+      'prorationMode': _$ProrationModeEnumMap[instance.prorationMode],
     };
 
 K _$enumDecode<K, V>(
@@ -72,3 +75,11 @@
   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 50560a6..d4601fd 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 @@
 
   @override
   Future<bool> 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 ef0b7d2..1a47f3e 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 @@
     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 c211d2a..b4a5090 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 @@
       {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 @@
   ///
   /// 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 f847a81..6175e8c 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 d415007..3aa62dd 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 @@
       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<dynamic, dynamic> 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<dynamic, dynamic> 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 7f3de27..df5b8f5 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 @@
   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', () {