Skip to content

Commit 41ace12

Browse files
rahulraj64adsonpleal
authored andcommitted
[in_app_purchase] Add support for InApp subscription upgrade/downgrade (flutter#2822)
1 parent 25cb86e commit 41ace12

File tree

16 files changed

+491
-15
lines changed

16 files changed

+491
-15
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,4 @@ Juan Alvarez <[email protected]>
6262
Aleksandr Yurkovskiy <[email protected]>
6363
Anton Borries <[email protected]>
6464
65+
Rahul Raj <[email protected]>

packages/in_app_purchase/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.4.1
2+
3+
* Support InApp subscription upgrade/downgrade.
4+
15
## 0.4.0
26

37
* Migrate to nullsafety.

packages/in_app_purchase/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,30 @@ and `AppStore` that the purchase has been finished.
178178

179179
WARNING! Failure to call `InAppPurchaseConnection.completePurchase` and get a successful response within 3 days of the purchase will result a refund.
180180

181+
### Upgrading or Downgrading an existing InApp Subscription
182+
183+
In order to upgrade/downgrade an existing InApp subscription on `PlayStore`,
184+
you need to provide an instance of `ChangeSubscriptionParam` with the old
185+
`PurchaseDetails` that the user needs to migrate from, and an optional `ProrationMode`
186+
with the `PurchaseParam` object while calling `InAppPurchaseConnection.buyNonConsumable`.
187+
`AppStore` does not require this since they provides a subscription grouping mechanism.
188+
Each subscription you offer must be assigned to a subscription group.
189+
So the developers can group related subscriptions together to prevents users from
190+
accidentally purchasing multiple subscriptions.
191+
Please refer to the 'Creating a Subscription Group' sections of [Apple's subscription guide](https://developer.apple.com/app-store/subscriptions/)
192+
193+
194+
```dart
195+
final PurchaseDetails oldPurchaseDetails = ...;
196+
PurchaseParam purchaseParam = PurchaseParam(
197+
productDetails: productDetails,
198+
changeSubscriptionParam: ChangeSubscriptionParam(
199+
oldPurchaseDetails: oldPurchaseDetails,
200+
prorationMode: ProrationMode.immediateWithTimeProration));
201+
InAppPurchaseConnection.instance
202+
.buyNonConsumable(purchaseParam: purchaseParam);
203+
```
204+
181205
## Development
182206

183207
This plugin uses

packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import com.android.billingclient.api.BillingClient;
2121
import com.android.billingclient.api.BillingClientStateListener;
2222
import com.android.billingclient.api.BillingFlowParams;
23+
import com.android.billingclient.api.BillingFlowParams.ProrationMode;
2324
import com.android.billingclient.api.BillingResult;
2425
import com.android.billingclient.api.ConsumeParams;
2526
import com.android.billingclient.api.ConsumeResponseListener;
@@ -39,6 +40,8 @@ class MethodCallHandlerImpl
3940
implements MethodChannel.MethodCallHandler, Application.ActivityLifecycleCallbacks {
4041

4142
private static final String TAG = "InAppPurchasePlugin";
43+
private static final String LOAD_SKU_DOC_URL =
44+
"https://github.com/flutter/plugins/blob/master/packages/in_app_purchase/README.md#loading-products-for-sale";
4245

4346
@Nullable private BillingClient billingClient;
4447
private final BillingClientFactory billingClientFactory;
@@ -120,7 +123,13 @@ public void onMethodCall(MethodCall call, MethodChannel.Result result) {
120123
break;
121124
case InAppPurchasePlugin.MethodNames.LAUNCH_BILLING_FLOW:
122125
launchBillingFlow(
123-
(String) call.argument("sku"), (String) call.argument("accountId"), result);
126+
(String) call.argument("sku"),
127+
(String) call.argument("accountId"),
128+
(String) call.argument("oldSku"),
129+
call.hasArgument("prorationMode")
130+
? (int) call.argument("prorationMode")
131+
: ProrationMode.UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY,
132+
result);
124133
break;
125134
case InAppPurchasePlugin.MethodNames.QUERY_PURCHASES:
126135
queryPurchases((String) call.argument("skuType"), result);
@@ -189,7 +198,11 @@ public void onSkuDetailsResponse(
189198
}
190199

191200
private void launchBillingFlow(
192-
String sku, @Nullable String accountId, MethodChannel.Result result) {
201+
String sku,
202+
@Nullable String accountId,
203+
@Nullable String oldSku,
204+
int prorationMode,
205+
MethodChannel.Result result) {
193206
if (billingClientError(result)) {
194207
return;
195208
}
@@ -198,7 +211,26 @@ private void launchBillingFlow(
198211
if (skuDetails == null) {
199212
result.error(
200213
"NOT_FOUND",
201-
"Details for sku " + sku + " are not available. Has this ID already been fetched?",
214+
String.format(
215+
"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",
216+
sku, LOAD_SKU_DOC_URL),
217+
null);
218+
return;
219+
}
220+
221+
if (oldSku == null
222+
&& prorationMode != ProrationMode.UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY) {
223+
result.error(
224+
"IN_APP_PURCHASE_REQUIRE_OLD_SKU",
225+
"launchBillingFlow failed because oldSku is null. You must provide a valid oldSku in order to use a proration mode.",
226+
null);
227+
return;
228+
} else if (oldSku != null && !cachedSkus.containsKey(oldSku)) {
229+
result.error(
230+
"IN_APP_PURCHASE_INVALID_OLD_SKU",
231+
String.format(
232+
"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",
233+
oldSku, LOAD_SKU_DOC_URL),
202234
null);
203235
return;
204236
}
@@ -218,6 +250,12 @@ private void launchBillingFlow(
218250
if (accountId != null && !accountId.isEmpty()) {
219251
paramsBuilder.setAccountId(accountId);
220252
}
253+
if (oldSku != null && !oldSku.isEmpty()) {
254+
paramsBuilder.setOldSku(oldSku);
255+
}
256+
// The proration mode value has to match one of the following declared in
257+
// https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProrationMode
258+
paramsBuilder.setReplaceSkusProrationMode(prorationMode);
221259
result.success(
222260
Translator.fromBillingResult(
223261
billingClient.launchBillingFlow(activity, paramsBuilder.build())));
@@ -252,7 +290,8 @@ private void queryPurchases(String skuType, MethodChannel.Result result) {
252290
return;
253291
}
254292

255-
// Like in our connect call, consider the billing client responding a "success" here regardless of status code.
293+
// Like in our connect call, consider the billing client responding a "success" here regardless
294+
// of status code.
256295
result.success(fromPurchasesResult(billingClient.queryPurchases(skuType)));
257296
}
258297

@@ -295,7 +334,8 @@ public void onBillingSetupFinished(BillingResult billingResult) {
295334
return;
296335
}
297336
alreadyFinished = true;
298-
// Consider the fact that we've finished a success, leave it to the Dart side to validate the responseCode.
337+
// Consider the fact that we've finished a success, leave it to the Dart side to
338+
// validate the responseCode.
299339
result.success(Translator.fromBillingResult(billingResult));
300340
}
301341

packages/in_app_purchase/example/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ below.
3030

3131
- `consumable`: A managed product.
3232
- `upgrade`: A managed product.
33-
- `subscription`: A subscription.
33+
- `subscription_silver`: A lower level subscription.
34+
- `subscription_gold`: A higher level subscription.
3435

3536
Make sure that all of the products are set to `ACTIVE`.
3637

packages/in_app_purchase/example/android/app/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import static io.flutter.plugins.inapppurchase.Translator.fromSkuDetailsList;
1919
import static java.util.Arrays.asList;
2020
import static java.util.Collections.singletonList;
21+
import static java.util.Collections.unmodifiableList;
2122
import static java.util.stream.Collectors.toList;
2223
import static org.junit.Assert.assertEquals;
2324
import static org.junit.Assert.assertNull;
@@ -261,7 +262,7 @@ public void querySkuDetailsAsync_clientDisconnected() {
261262
}
262263

263264
@Test
264-
public void launchBillingFlow_ok_nullAccountId() {
265+
public void launchBillingFlow_ok_null_AccountId() {
265266
// Fetch the sku details first and then prepare the launch billing flow call
266267
String skuId = "foo";
267268
queryForSkus(singletonList(skuId));
@@ -292,6 +293,40 @@ public void launchBillingFlow_ok_nullAccountId() {
292293
verify(result, times(1)).success(fromBillingResult(billingResult));
293294
}
294295

296+
@Test
297+
public void launchBillingFlow_ok_null_OldSku() {
298+
// Fetch the sku details first and then prepare the launch billing flow call
299+
String skuId = "foo";
300+
String accountId = "account";
301+
queryForSkus(singletonList(skuId));
302+
HashMap<String, Object> arguments = new HashMap<>();
303+
arguments.put("sku", skuId);
304+
arguments.put("accountId", accountId);
305+
arguments.put("oldSku", null);
306+
MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments);
307+
308+
// Launch the billing flow
309+
BillingResult billingResult =
310+
BillingResult.newBuilder()
311+
.setResponseCode(100)
312+
.setDebugMessage("dummy debug message")
313+
.build();
314+
when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult);
315+
methodChannelHandler.onMethodCall(launchCall, result);
316+
317+
// Verify we pass the arguments to the billing flow
318+
ArgumentCaptor<BillingFlowParams> billingFlowParamsCaptor =
319+
ArgumentCaptor.forClass(BillingFlowParams.class);
320+
verify(mockBillingClient).launchBillingFlow(any(), billingFlowParamsCaptor.capture());
321+
BillingFlowParams params = billingFlowParamsCaptor.getValue();
322+
assertEquals(params.getSku(), skuId);
323+
assertEquals(params.getAccountId(), accountId);
324+
assertNull(params.getOldSku());
325+
// Verify we pass the response code to result
326+
verify(result, never()).error(any(), any(), any());
327+
verify(result, times(1)).success(fromBillingResult(billingResult));
328+
}
329+
295330
@Test
296331
public void launchBillingFlow_ok_null_Activity() {
297332
methodChannelHandler.setActivity(null);
@@ -311,6 +346,42 @@ public void launchBillingFlow_ok_null_Activity() {
311346
verify(result, never()).success(any());
312347
}
313348

349+
@Test
350+
public void launchBillingFlow_ok_oldSku() {
351+
// Fetch the sku details first and query the method call
352+
String skuId = "foo";
353+
String accountId = "account";
354+
String oldSkuId = "oldFoo";
355+
queryForSkus(unmodifiableList(asList(skuId, oldSkuId)));
356+
HashMap<String, Object> arguments = new HashMap<>();
357+
arguments.put("sku", skuId);
358+
arguments.put("accountId", accountId);
359+
arguments.put("oldSku", oldSkuId);
360+
MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments);
361+
362+
// Launch the billing flow
363+
BillingResult billingResult =
364+
BillingResult.newBuilder()
365+
.setResponseCode(100)
366+
.setDebugMessage("dummy debug message")
367+
.build();
368+
when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult);
369+
methodChannelHandler.onMethodCall(launchCall, result);
370+
371+
// Verify we pass the arguments to the billing flow
372+
ArgumentCaptor<BillingFlowParams> billingFlowParamsCaptor =
373+
ArgumentCaptor.forClass(BillingFlowParams.class);
374+
verify(mockBillingClient).launchBillingFlow(any(), billingFlowParamsCaptor.capture());
375+
BillingFlowParams params = billingFlowParamsCaptor.getValue();
376+
assertEquals(params.getSku(), skuId);
377+
assertEquals(params.getAccountId(), accountId);
378+
assertEquals(params.getOldSku(), oldSkuId);
379+
380+
// Verify we pass the response code to result
381+
verify(result, never()).error(any(), any(), any());
382+
verify(result, times(1)).success(fromBillingResult(billingResult));
383+
}
384+
314385
@Test
315386
public void launchBillingFlow_ok_AccountId() {
316387
// Fetch the sku details first and query the method call
@@ -344,6 +415,79 @@ public void launchBillingFlow_ok_AccountId() {
344415
verify(result, times(1)).success(fromBillingResult(billingResult));
345416
}
346417

418+
@Test
419+
public void launchBillingFlow_ok_Proration() {
420+
// Fetch the sku details first and query the method call
421+
String skuId = "foo";
422+
String oldSkuId = "oldFoo";
423+
String accountId = "account";
424+
int prorationMode = BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE;
425+
queryForSkus(unmodifiableList(asList(skuId, oldSkuId)));
426+
HashMap<String, Object> arguments = new HashMap<>();
427+
arguments.put("sku", skuId);
428+
arguments.put("accountId", accountId);
429+
arguments.put("oldSku", oldSkuId);
430+
arguments.put("prorationMode", prorationMode);
431+
MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments);
432+
433+
// Launch the billing flow
434+
BillingResult billingResult =
435+
BillingResult.newBuilder()
436+
.setResponseCode(100)
437+
.setDebugMessage("dummy debug message")
438+
.build();
439+
when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult);
440+
methodChannelHandler.onMethodCall(launchCall, result);
441+
442+
// Verify we pass the arguments to the billing flow
443+
ArgumentCaptor<BillingFlowParams> billingFlowParamsCaptor =
444+
ArgumentCaptor.forClass(BillingFlowParams.class);
445+
verify(mockBillingClient).launchBillingFlow(any(), billingFlowParamsCaptor.capture());
446+
BillingFlowParams params = billingFlowParamsCaptor.getValue();
447+
assertEquals(params.getSku(), skuId);
448+
assertEquals(params.getAccountId(), accountId);
449+
assertEquals(params.getOldSku(), oldSkuId);
450+
assertEquals(params.getReplaceSkusProrationMode(), prorationMode);
451+
452+
// Verify we pass the response code to result
453+
verify(result, never()).error(any(), any(), any());
454+
verify(result, times(1)).success(fromBillingResult(billingResult));
455+
}
456+
457+
@Test
458+
public void launchBillingFlow_ok_Proration_with_null_OldSku() {
459+
// Fetch the sku details first and query the method call
460+
String skuId = "foo";
461+
String accountId = "account";
462+
String queryOldSkuId = "oldFoo";
463+
String oldSkuId = null;
464+
int prorationMode = BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE;
465+
queryForSkus(unmodifiableList(asList(skuId, queryOldSkuId)));
466+
HashMap<String, Object> arguments = new HashMap<>();
467+
arguments.put("sku", skuId);
468+
arguments.put("accountId", accountId);
469+
arguments.put("oldSku", oldSkuId);
470+
arguments.put("prorationMode", prorationMode);
471+
MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments);
472+
473+
// Launch the billing flow
474+
BillingResult billingResult =
475+
BillingResult.newBuilder()
476+
.setResponseCode(100)
477+
.setDebugMessage("dummy debug message")
478+
.build();
479+
when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult);
480+
methodChannelHandler.onMethodCall(launchCall, result);
481+
482+
// Assert that we sent an error back.
483+
verify(result)
484+
.error(
485+
contains("IN_APP_PURCHASE_REQUIRE_OLD_SKU"),
486+
contains("launchBillingFlow failed because oldSku is null"),
487+
any());
488+
verify(result, never()).success(any());
489+
}
490+
347491
@Test
348492
public void launchBillingFlow_clientDisconnected() {
349493
// Prepare the launch call after disconnecting the client
@@ -381,6 +525,27 @@ public void launchBillingFlow_skuNotFound() {
381525
verify(result, never()).success(any());
382526
}
383527

528+
@Test
529+
public void launchBillingFlow_oldSkuNotFound() {
530+
// Try to launch the billing flow for a random sku ID
531+
establishConnectedBillingClient(null, null);
532+
String skuId = "foo";
533+
String accountId = "account";
534+
String oldSkuId = "oldSku";
535+
queryForSkus(singletonList(skuId));
536+
HashMap<String, Object> arguments = new HashMap<>();
537+
arguments.put("sku", skuId);
538+
arguments.put("accountId", accountId);
539+
arguments.put("oldSku", oldSkuId);
540+
MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments);
541+
542+
methodChannelHandler.onMethodCall(launchCall, result);
543+
544+
// Assert that we sent an error back.
545+
verify(result).error(contains("IN_APP_PURCHASE_INVALID_OLD_SKU"), contains(oldSkuId), any());
546+
verify(result, never()).success(any());
547+
}
548+
384549
@Test
385550
public void queryPurchases() {
386551
establishConnectedBillingClient(null, null);

0 commit comments

Comments
 (0)