Skip to content

Commit

Permalink
[in_app_purchase] Add support for InApp subscription upgrade/downgrade (
Browse files Browse the repository at this point in the history
  • Loading branch information
rahulraj64 authored Feb 25, 2021
1 parent 98e289b commit bc11bad
Show file tree
Hide file tree
Showing 16 changed files with 491 additions and 15 deletions.
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ Juan Alvarez <[email protected]>
Aleksandr Yurkovskiy <[email protected]>
Anton Borries <[email protected]>
Alex Li <[email protected]>
Rahul Raj <[email protected]>
4 changes: 4 additions & 0 deletions packages/in_app_purchase/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 0.4.1

* Support InApp subscription upgrade/downgrade.

## 0.4.0

* Migrate to nullsafety.
Expand Down
24 changes: 24 additions & 0 deletions packages/in_app_purchase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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())));
Expand Down Expand Up @@ -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)));
}

Expand Down Expand Up @@ -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));
}

Expand Down
3 changes: 2 additions & 1 deletion packages/in_app_purchase/example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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<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);
Expand All @@ -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<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
Expand Down Expand Up @@ -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<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
Expand Down Expand Up @@ -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<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);
Expand Down
Loading

0 comments on commit bc11bad

Please sign in to comment.