diff --git a/android/src/main/java/com/revenuecat/purchases_flutter/PurchasesFlutterPlugin.java b/android/src/main/java/com/revenuecat/purchases_flutter/PurchasesFlutterPlugin.java index 399fa0f2b..f4f1f7c49 100644 --- a/android/src/main/java/com/revenuecat/purchases_flutter/PurchasesFlutterPlugin.java +++ b/android/src/main/java/com/revenuecat/purchases_flutter/PurchasesFlutterPlugin.java @@ -156,8 +156,12 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { Boolean googleIsPersonalizedPrice = call.argument("googleIsPersonalizedPrice"); type = call.argument("type"); Map presentedOfferingContext = call.argument("presentedOfferingContext"); + List> addOnStoreProducts = call.argument("addOnStoreProducts"); + List> addOnSubscriptionOptions = call.argument("addOnSubscriptionOptions"); + List> addOnPackages = call.argument("addOnPackages"); purchaseProduct(productIdentifier, type, googleOldProductIdentifer, googleProrationMode, - googleIsPersonalizedPrice, presentedOfferingContext, result); + googleIsPersonalizedPrice, presentedOfferingContext, addOnStoreProducts, + addOnSubscriptionOptions, addOnPackages, result); break; case "purchasePackage": String packageIdentifier = call.argument("packageIdentifier"); @@ -165,8 +169,12 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { googleOldProductIdentifer = call.argument("googleOldProductIdentifier"); googleProrationMode = call.argument("googleProrationMode"); googleIsPersonalizedPrice = call.argument("googleIsPersonalizedPrice"); + addOnStoreProducts = call.argument("addOnStoreProducts"); + addOnSubscriptionOptions = call.argument("addOnSubscriptionOptions"); + addOnPackages = call.argument("addOnPackages"); purchasePackage(packageIdentifier, presentedOfferingContext, googleOldProductIdentifer, - googleProrationMode, googleIsPersonalizedPrice, result); + googleProrationMode, googleIsPersonalizedPrice, addOnStoreProducts, + addOnSubscriptionOptions, addOnPackages, result); break; case "purchaseSubscriptionOption": productIdentifier = call.argument("productIdentifier"); @@ -175,8 +183,12 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { googleProrationMode = call.argument("googleProrationMode"); googleIsPersonalizedPrice = call.argument("googleIsPersonalizedPrice"); presentedOfferingContext = call.argument("presentedOfferingContext"); + addOnStoreProducts = call.argument("addOnStoreProducts"); + addOnSubscriptionOptions = call.argument("addOnSubscriptionOptions"); + addOnPackages = call.argument("addOnPackages"); purchaseSubscriptionOption(productIdentifier, optionIdentifier, googleOldProductIdentifer, - googleProrationMode, googleIsPersonalizedPrice, presentedOfferingContext, result); + googleProrationMode, googleIsPersonalizedPrice, presentedOfferingContext, + addOnStoreProducts, addOnSubscriptionOptions, addOnPackages, result); break; case "getAppUserID": getAppUserID(result); @@ -476,6 +488,9 @@ private void purchaseProduct(final String productIdentifier, @Nullable final Integer googleProrationMode, @Nullable final Boolean googleIsPersonalizedPrice, @Nullable final Map presentedOfferingContext, + @Nullable final List> addOnStoreProducts, + @Nullable final List> addOnSubscriptionOptions, + @Nullable final List> addOnPackages, final Result result) { CommonKt.purchaseProduct( activity, @@ -486,7 +501,10 @@ private void purchaseProduct(final String productIdentifier, googleProrationMode, googleIsPersonalizedPrice, presentedOfferingContext, - getOnResult(result)); + getOnResult(result), + addOnStoreProducts, + addOnSubscriptionOptions, + addOnPackages); } private void purchasePackage(final String packageIdentifier, @@ -494,6 +512,9 @@ private void purchasePackage(final String packageIdentifier, final String googleOldProductId, @Nullable final Integer googleProrationMode, @Nullable final Boolean googleIsPersonalizedPrice, + @Nullable final List> addOnStoreProducts, + @Nullable final List> addOnSubscriptionOptions, + @Nullable final List> addOnPackages, final Result result) { CommonKt.purchasePackage( activity, @@ -502,7 +523,10 @@ private void purchasePackage(final String packageIdentifier, googleOldProductId, googleProrationMode, googleIsPersonalizedPrice, - getOnResult(result)); + getOnResult(result), + addOnStoreProducts, + addOnSubscriptionOptions, + addOnPackages); } private void purchaseSubscriptionOption(final String productIdentifier, @@ -511,6 +535,9 @@ private void purchaseSubscriptionOption(final String productIdentifier, @Nullable final Integer googleProrationMode, @Nullable final Boolean googleIsPersonalizedPrice, @Nullable final Map presentedOfferingContext, + @Nullable final List> addOnStoreProducts, + @Nullable final List> addOnSubscriptionOptions, + @Nullable final List> addOnPackages, final Result result) { CommonKt.purchaseSubscriptionOption( activity, @@ -520,7 +547,10 @@ private void purchaseSubscriptionOption(final String productIdentifier, googleProrationMode, googleIsPersonalizedPrice, presentedOfferingContext, - getOnResult(result)); + getOnResult(result), + addOnStoreProducts, + addOnSubscriptionOptions, + addOnPackages); } private void getAppUserID(final Result result) { diff --git a/api_tester/lib/api_tests/models/purchase_params_api_test.dart b/api_tester/lib/api_tests/models/purchase_params_api_test.dart index a4d1a26ee..141fc477c 100644 --- a/api_tester/lib/api_tests/models/purchase_params_api_test.dart +++ b/api_tester/lib/api_tests/models/purchase_params_api_test.dart @@ -11,6 +11,9 @@ class _PurchaseParamsApiTest { PromotionalOffer? promotionalOffer, WinBackOffer? winBackOffer, String? customerEmail, + List? addOnStoreProducts, + List? addOnSubscriptionOptions, + List? addOnPackages, ) { PurchaseParams purchaseParams = PurchaseParams.package( package, @@ -45,6 +48,44 @@ class _PurchaseParamsApiTest { winBackOffer: winBackOffer, customerEmail: customerEmail, ); + purchaseParams = PurchaseParams.package( + package, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + promotionalOffer: promotionalOffer, + winBackOffer: winBackOffer, + customerEmail: customerEmail, + addOnStoreProducts: addOnStoreProducts, + ); + purchaseParams = PurchaseParams.package( + package, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + promotionalOffer: promotionalOffer, + winBackOffer: winBackOffer, + customerEmail: customerEmail, + addOnPackages: addOnPackages, + ); + purchaseParams = PurchaseParams.package( + package, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + promotionalOffer: promotionalOffer, + winBackOffer: winBackOffer, + customerEmail: customerEmail, + addOnSubscriptionOptions: addOnSubscriptionOptions, + ); + purchaseParams = PurchaseParams.package( + package, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + promotionalOffer: promotionalOffer, + winBackOffer: winBackOffer, + customerEmail: customerEmail, + addOnStoreProducts: addOnStoreProducts, + addOnSubscriptionOptions: addOnSubscriptionOptions, + addOnPackages: addOnPackages, + ); } void _checkStoreProductConstructor( @@ -54,6 +95,9 @@ class _PurchaseParamsApiTest { PromotionalOffer? promotionalOffer, WinBackOffer? winBackOffer, String? customerEmail, + List? addOnStoreProducts, + List? addOnSubscriptionOptions, + List? addOnPackages, ) { PurchaseParams purchaseParams = PurchaseParams.storeProduct( storeProduct, @@ -88,6 +132,44 @@ class _PurchaseParamsApiTest { winBackOffer: winBackOffer, customerEmail: customerEmail, ); + purchaseParams = PurchaseParams.storeProduct( + storeProduct, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + promotionalOffer: promotionalOffer, + winBackOffer: winBackOffer, + customerEmail: customerEmail, + addOnStoreProducts: addOnStoreProducts, + ); + purchaseParams = PurchaseParams.storeProduct( + storeProduct, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + promotionalOffer: promotionalOffer, + winBackOffer: winBackOffer, + customerEmail: customerEmail, + addOnPackages: addOnPackages, + ); + purchaseParams = PurchaseParams.storeProduct( + storeProduct, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + promotionalOffer: promotionalOffer, + winBackOffer: winBackOffer, + customerEmail: customerEmail, + addOnSubscriptionOptions: addOnSubscriptionOptions, + ); + purchaseParams = PurchaseParams.storeProduct( + storeProduct, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + promotionalOffer: promotionalOffer, + winBackOffer: winBackOffer, + customerEmail: customerEmail, + addOnStoreProducts: addOnStoreProducts, + addOnSubscriptionOptions: addOnSubscriptionOptions, + addOnPackages: addOnPackages, + ); } void _checkSubscriptionOptionConstructor( @@ -95,6 +177,9 @@ class _PurchaseParamsApiTest { GoogleProductChangeInfo? googleProductChangeInfo, bool? googleIsPersonalizedPrice, String? customerEmail, + List? addOnStoreProducts, + List? addOnSubscriptionOptions, + List? addOnPackages, ) { PurchaseParams purchaseParams = PurchaseParams.subscriptionOption( subscriptionOption, @@ -113,7 +198,38 @@ class _PurchaseParamsApiTest { googleProductChangeInfo: googleProductChangeInfo, googleIsPersonalizedPrice: googleIsPersonalizedPrice, customerEmail: customerEmail, - );} + ); + purchaseParams = PurchaseParams.subscriptionOption( + subscriptionOption, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + customerEmail: customerEmail, + addOnStoreProducts: addOnStoreProducts, + ); + purchaseParams = PurchaseParams.subscriptionOption( + subscriptionOption, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + customerEmail: customerEmail, + addOnPackages: addOnPackages, + ); + purchaseParams = PurchaseParams.subscriptionOption( + subscriptionOption, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + customerEmail: customerEmail, + addOnSubscriptionOptions: addOnSubscriptionOptions, + ); + purchaseParams = PurchaseParams.subscriptionOption( + subscriptionOption, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + customerEmail: customerEmail, + addOnStoreProducts: addOnStoreProducts, + addOnSubscriptionOptions: addOnSubscriptionOptions, + addOnPackages: addOnPackages, + ); + } void _checkProperties(PurchaseParams purchaseParams) { Package? package = purchaseParams.package; @@ -124,5 +240,8 @@ class _PurchaseParamsApiTest { PromotionalOffer? promotionalOffer = purchaseParams.promotionalOffer; WinBackOffer? winBackOffer = purchaseParams.winBackOffer; String? customerEmail = purchaseParams.customerEmail; + List? addOnStoreProducts = purchaseParams.addOnStoreProducts; + List? addOnSubscriptionOptions = purchaseParams.addOnSubscriptionOptions; + List? addOnPackages = purchaseParams.addOnPackages; } } diff --git a/lib/models/purchase_params.dart b/lib/models/purchase_params.dart index eeb51be2a..26a76438e 100644 --- a/lib/models/purchase_params.dart +++ b/lib/models/purchase_params.dart @@ -15,6 +15,9 @@ class PurchaseParams { final PromotionalOffer? promotionalOffer; final WinBackOffer? winBackOffer; final String? customerEmail; + final List? addOnStoreProducts; + final List? addOnSubscriptionOptions; + final List? addOnPackages; const PurchaseParams._( this.package, @@ -25,6 +28,9 @@ class PurchaseParams { this.promotionalOffer, this.winBackOffer, this.customerEmail, + this.addOnStoreProducts, + this.addOnSubscriptionOptions, + this.addOnPackages, ); /// Creates purchase parameters for a package. @@ -52,6 +58,12 @@ class PurchaseParams { /// [customerEmail] Web only. The email of the user. If undefined, RevenueCat /// will ask the customer for their email. /// + /// [addOnStoreProducts] Play Store only. Add-on products to be purchased with the base item. + /// + /// [addOnSubscriptionOptions] Play Store only. Add-on subscription options to be purchased with the base item. + /// + /// [addOnPackages] Play Store only. Add-on packages to be purchased with the base item. + /// const PurchaseParams.package( Package package, { GoogleProductChangeInfo? googleProductChangeInfo, @@ -59,6 +71,9 @@ class PurchaseParams { PromotionalOffer? promotionalOffer, WinBackOffer? winBackOffer, String? customerEmail, + List? addOnStoreProducts, + List? addOnSubscriptionOptions, + List? addOnPackages, }) : this._( package, null, @@ -68,6 +83,9 @@ class PurchaseParams { promotionalOffer, winBackOffer, customerEmail, + addOnStoreProducts, + addOnSubscriptionOptions, + addOnPackages, ); /// Creates purchase parameters for a store product. @@ -96,6 +114,12 @@ class PurchaseParams { /// [customerEmail] Web only. The email of the user. If undefined, RevenueCat /// will ask the customer for their email. /// + /// [addOnStoreProducts] Play Store only. Add-on products to be purchased with the base item. + /// + /// [addOnSubscriptionOptions] Play Store only. Add-on subscription options to be purchased with the base item. + /// + /// [addOnPackages] Play Store only. Add-on packages to be purchased with the base item. + /// const PurchaseParams.storeProduct( StoreProduct storeProduct, { GoogleProductChangeInfo? googleProductChangeInfo, @@ -103,6 +127,9 @@ class PurchaseParams { PromotionalOffer? promotionalOffer, WinBackOffer? winBackOffer, String? customerEmail, + List? addOnStoreProducts, + List? addOnSubscriptionOptions, + List? addOnPackages, }) : this._( null, storeProduct, @@ -112,6 +139,9 @@ class PurchaseParams { promotionalOffer, winBackOffer, customerEmail, + addOnStoreProducts, + addOnSubscriptionOptions, + addOnPackages, ); /// Creates purchase parameters for a subscription option. Google Play-only. @@ -131,11 +161,20 @@ class PurchaseParams { /// [customerEmail] Web only. The email of the user. If undefined, RevenueCat /// will ask the customer for their email. /// + /// [addOnStoreProducts] Play Store only. Add-on products to be purchased with the base item. + /// + /// [addOnSubscriptionOptions] Play Store only. Add-on subscription options to be purchased with the base item. + /// + /// [addOnPackages] Play Store only. Add-on packages to be purchased with the base item. + /// const PurchaseParams.subscriptionOption( SubscriptionOption subscriptionOption, { GoogleProductChangeInfo? googleProductChangeInfo, bool? googleIsPersonalizedPrice, String? customerEmail, + List? addOnStoreProducts, + List? addOnSubscriptionOptions, + List? addOnPackages, }) : this._( null, null, @@ -145,5 +184,8 @@ class PurchaseParams { null, null, customerEmail, + addOnStoreProducts, + addOnSubscriptionOptions, + addOnPackages, ); } diff --git a/lib/purchases_flutter.dart b/lib/purchases_flutter.dart index 30fff1bd0..06989ee9a 100644 --- a/lib/purchases_flutter.dart +++ b/lib/purchases_flutter.dart @@ -595,6 +595,27 @@ class Purchases { purchaseParams.product?.presentedOfferingContext ?? purchaseParams.subscriptionOption?.presentedOfferingContext; final presentedOfferingContextJson = presentedOfferingContext?.toJson(); + final addOnStoreProducts = purchaseParams.addOnStoreProducts + ?.map((storeProduct) => { + 'productIdentifier': storeProduct.identifier, + 'type': storeProduct.productCategory?.name, + },) + .toList(); + final addOnSubscriptionOptions = purchaseParams.addOnSubscriptionOptions + ?.map((subscriptionOption) => { + 'productIdentifier': subscriptionOption.productId, + 'optionIdentifier': subscriptionOption.id, + },) + .toList(); + final addOnPackages = purchaseParams.addOnPackages + ?.map( + (package) => { + 'packageIdentifier': package.identifier, + 'presentedOfferingContext': + package.presentedOfferingContext.toJson(), + }, + ) + .toList(); final purchaseArgs = { 'googleOldProductIdentifier': googleProductChangeInfo?.oldProductIdentifier, 'googleProrationMode': prorationMode, @@ -603,6 +624,9 @@ class Purchases { 'presentedOfferingContext': presentedOfferingContextJson, 'customerEmail': customerEmail, 'winBackOfferIdentifier': winBackOffer?.identifier, + 'addOnStoreProducts': addOnStoreProducts, + 'addOnSubscriptionOptions': addOnSubscriptionOptions, + 'addOnPackages': addOnPackages, }; final isWinBackOfferPurchase = (defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.macOS) diff --git a/revenuecat_examples/purchase_tester/lib/src/add_on_purchasing_screen.dart b/revenuecat_examples/purchase_tester/lib/src/add_on_purchasing_screen.dart new file mode 100644 index 000000000..807cc28fa --- /dev/null +++ b/revenuecat_examples/purchase_tester/lib/src/add_on_purchasing_screen.dart @@ -0,0 +1,452 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:purchases_flutter/purchases_flutter.dart'; + +class AddOnPurchasingScreen extends StatefulWidget { + final Offering offering; + + const AddOnPurchasingScreen({super.key, required this.offering}); + + @override + State createState() => _AddOnPurchasingScreenState(); +} + +class _AddOnPurchasingScreenState extends State { + late final List<_SubscriptionOptionEntry> _options; + String? _selectedBaseOptionId; + final Set _selectedPurchaseOptionIds = {}; + final Map _purchaseAsByOption = + {}; + String? _purchaseStatusMessage; + bool _isPurchasing = false; + + bool get _canAttemptPurchase => + !_isPurchasing && _selectedBaseOptionId != null; + + @override + void initState() { + super.initState(); + _options = _extractOptions(widget.offering); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Add-On Purchasing')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Allows you to test subscriptions with add-ons.', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _canAttemptPurchase ? () => _onPurchasePressed() : null, + child: const Text('Purchase'), + ), + if (_isPurchasing) + const Padding( + padding: EdgeInsets.only(top: 12), + child: LinearProgressIndicator(), + ), + if (_purchaseStatusMessage != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + _purchaseStatusMessage!, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + const SizedBox(height: 24), + Expanded(child: _buildOptionsList(context)), + ], + ), + ), + ); + } + + Widget _buildOptionsList(BuildContext context) { + if (_options.isEmpty) { + return const Center( + child: Text('No subscription options available for this offering.'), + ); + } + + return ListView.separated( + itemCount: _options.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final entry = _options[index]; + final option = entry.option; + final bool isBase = _selectedBaseOptionId == option.id; + final bool isPurchaseOption = + _selectedPurchaseOptionIds.contains(option.id); + final purchaseAs = _purchaseAsFor(option); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.storeProduct.title, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + 'Option: ${option.id}', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + _formatPricing(option), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _OptionCheckbox( + label: 'Base Item', + value: isBase, + onChanged: (checked) => + _onBaseItemToggled(option, checked), + ), + if (!isBase) + _OptionCheckbox( + label: 'Purchase Option', + value: isPurchaseOption, + onChanged: (checked) => + _onPurchaseOptionToggled(option, checked), + ), + const SizedBox(height: 16), + Text( + 'Purchase As:', + style: Theme.of(context).textTheme.titleSmall, + ), + _PurchaseAsPicker( + label: 'Store Product', + value: _PurchaseAs.storeProduct, + groupValue: purchaseAs, + onChanged: (value) => _onPurchaseAsChanged(option, value), + ), + _PurchaseAsPicker( + label: 'Subscription Option', + value: _PurchaseAs.subscriptionOption, + groupValue: purchaseAs, + onChanged: (value) => _onPurchaseAsChanged(option, value), + ), + _PurchaseAsPicker( + label: 'Package', + value: _PurchaseAs.package, + groupValue: purchaseAs, + onChanged: (value) => _onPurchaseAsChanged(option, value), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } + + void _onBaseItemToggled(SubscriptionOption option, bool checked) { + setState(() { + if (checked) { + _selectedBaseOptionId = option.id; + _selectedPurchaseOptionIds.remove(option.id); + } else if (_selectedBaseOptionId == option.id) { + _selectedBaseOptionId = null; + } + }); + } + + void _onPurchaseOptionToggled(SubscriptionOption option, bool checked) { + setState(() { + if (checked) { + _selectedPurchaseOptionIds.add(option.id); + } else { + _selectedPurchaseOptionIds.remove(option.id); + } + }); + } + + void _onPurchaseAsChanged(SubscriptionOption option, _PurchaseAs value) { + setState(() { + _purchaseAsByOption[option.id] = value; + }); + } + + Future _onPurchasePressed() async { + final baseId = _selectedBaseOptionId; + if (baseId == null) return; + + final baseEntry = _findEntry(baseId); + if (baseEntry == null) return; + + final List<_SubscriptionOptionEntry> selectedAddOns = _selectedPurchaseOptionIds + .map(_findEntry) + .whereType<_SubscriptionOptionEntry>() + .where((entry) => entry.option.id != baseId) + .toList(growable: false); + + final basePurchaseAs = _purchaseAsFor(baseEntry.option); + + final Map addOnStoreProductsMap = {}; + final Map addOnSubscriptionOptionsMap = {}; + final Map addOnPackagesMap = {}; + + for (final entry in selectedAddOns) { + final selection = _purchaseAsFor(entry.option); + if (selection == _PurchaseAs.storeProduct) { + addOnStoreProductsMap[entry.storeProduct.identifier] = + entry.storeProduct; + } else if (selection == _PurchaseAs.subscriptionOption) { + debugPrint( + 'Adding add-on subscription option: ${entry.option.id}: ${entry.option.storeProductId}: ${entry.option.id}'); + addOnSubscriptionOptionsMap[entry.option.id] = entry.option; + } else { + addOnPackagesMap[entry.package.identifier] = entry.package; + } + } + + final List? addOnStoreProducts = + addOnStoreProductsMap.isNotEmpty + ? addOnStoreProductsMap.values.toList(growable: false) + : null; + final List? addOnSubscriptionOptions = + addOnSubscriptionOptionsMap.isNotEmpty + ? addOnSubscriptionOptionsMap.values.toList(growable: false) + : null; + final List? addOnPackages = addOnPackagesMap.isNotEmpty + ? addOnPackagesMap.values.toList(growable: false) + : null; + + final PurchaseParams params; + switch (basePurchaseAs) { + case _PurchaseAs.storeProduct: + params = PurchaseParams.storeProduct( + baseEntry.storeProduct, + addOnStoreProducts: addOnStoreProducts, + addOnPackages: addOnPackages, + addOnSubscriptionOptions: addOnSubscriptionOptions, + ); + break; + case _PurchaseAs.subscriptionOption: + params = PurchaseParams.subscriptionOption( + baseEntry.option, + addOnStoreProducts: addOnStoreProducts, + addOnPackages: addOnPackages, + addOnSubscriptionOptions: addOnSubscriptionOptions, + ); + break; + case _PurchaseAs.package: + params = PurchaseParams.package( + baseEntry.package, + addOnStoreProducts: addOnStoreProducts, + addOnPackages: addOnPackages, + addOnSubscriptionOptions: addOnSubscriptionOptions, + ); + break; + } + + final baseDescription = basePurchaseAs == _PurchaseAs.storeProduct + ? baseEntry.storeProduct.identifier + : basePurchaseAs == _PurchaseAs.subscriptionOption + ? '${baseEntry.option.storeProductId}:${baseEntry.option.id}' + : '${baseEntry.package.identifier} (package)'; + final addOnDescriptions = [ + if (addOnStoreProducts != null) + ...addOnStoreProducts.map((product) => product.identifier), + if (addOnSubscriptionOptions != null) + ...addOnSubscriptionOptions.map((option) => option.storeProductId), + if (addOnPackages != null) + ...addOnPackages.map((pkg) => '${pkg.identifier} (package)'), + ]; + final purchaseAsLabel = basePurchaseAs == _PurchaseAs.storeProduct + ? 'store product' + : basePurchaseAs == _PurchaseAs.subscriptionOption + ? 'subscription option' + : 'package'; + + final addOnSummary = + addOnDescriptions.isNotEmpty + ? ' with add-ons ${addOnDescriptions.join(', ')}' + : ''; + final attemptMessage = + 'Attempting purchase: $baseDescription as $purchaseAsLabel$addOnSummary'; + debugPrint(attemptMessage); + + try { + setState(() { + _isPurchasing = true; + _purchaseStatusMessage = null; + }); + final result = await Purchases.purchase(params); + final successMessage = + 'Purchase successful: ${result.storeTransaction.productIdentifier}$addOnSummary'; + debugPrint(successMessage); + setState(() { + _isPurchasing = false; + _purchaseStatusMessage = successMessage; + }); + } on PlatformException catch (error) { + debugPrint('Add-on purchase failed: ${error.message}'); + setState(() { + _isPurchasing = false; + final details = [ + if (error.code.isNotEmpty) error.code, + if (error.message != null) error.message!, + ].join(': '); + _purchaseStatusMessage = + 'Purchase failed${details.isNotEmpty ? ': $details' : ''}'; + }); + } catch (error) { + setState(() { + _isPurchasing = false; + _purchaseStatusMessage = 'Purchase failed: $error'; + }); + } + } + + _PurchaseAs _purchaseAsFor(SubscriptionOption option) => + _purchaseAsByOption[option.id] ?? _PurchaseAs.storeProduct; + + _SubscriptionOptionEntry? _findEntry(String optionId) { + for (final entry in _options) { + if (entry.option.id == optionId) return entry; + } + return null; + } + + List<_SubscriptionOptionEntry> _extractOptions(Offering offering) { + final Map deduped = {}; + for (final package in offering.availablePackages) { + final storeProduct = package.storeProduct; + final options = storeProduct.subscriptionOptions; + if (options == null) continue; + + for (final option in options) { + deduped.putIfAbsent( + option.id, + () => _SubscriptionOptionEntry( + option: option, + storeProduct: storeProduct, + package: package, + ), + ); + } + } + return deduped.values.toList() + ..sort((a, b) => a.option.id.compareTo(b.option.id)); + } +} + +class _SubscriptionOptionEntry { + final SubscriptionOption option; + final StoreProduct storeProduct; + + final Package package; + + _SubscriptionOptionEntry({ + required this.option, + required this.storeProduct, + required this.package, + }); +} + +class _OptionCheckbox extends StatelessWidget { + final String label; + final bool value; + final ValueChanged onChanged; + + const _OptionCheckbox({ + required this.label, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => onChanged(!value), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + value: value, + onChanged: (checked) => onChanged(checked ?? false), + ), + Text(label), + ], + ), + ); + } +} + +class _PurchaseAsPicker extends StatelessWidget { + final String label; + final _PurchaseAs value; + final _PurchaseAs groupValue; + final ValueChanged<_PurchaseAs> onChanged; + + const _PurchaseAsPicker({ + required this.label, + required this.value, + required this.groupValue, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final isSelected = value == groupValue; + return InkWell( + onTap: () => onChanged(value), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Radio<_PurchaseAs>( + value: value, + groupValue: groupValue, + onChanged: (_) => onChanged(value), + ), + Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ); + } +} + +enum _PurchaseAs { storeProduct, subscriptionOption, package } + +String _formatPricing(SubscriptionOption option) { + if (option.pricingPhases.isEmpty) { + return 'No pricing phases available'; + } + + final buffer = StringBuffer(); + for (final phase in option.pricingPhases) { + final price = phase.price.formatted; + final period = phase.billingPeriod?.iso8601 ?? 'one-time'; + buffer.writeln('$price • $period'); + } + return buffer.toString().trim(); +} diff --git a/revenuecat_examples/purchase_tester/lib/src/upsell.dart b/revenuecat_examples/purchase_tester/lib/src/upsell.dart index caa89ea28..6e472d2c3 100644 --- a/revenuecat_examples/purchase_tester/lib/src/upsell.dart +++ b/revenuecat_examples/purchase_tester/lib/src/upsell.dart @@ -7,6 +7,7 @@ import 'package:purchases_flutter/purchases_flutter.dart'; import 'package:purchases_flutter_example/src/paywall_footer_screen.dart'; import 'package:purchases_ui_flutter/purchases_ui_flutter.dart'; +import 'add_on_purchasing_screen.dart'; import 'cats.dart'; import 'constant.dart'; import 'customer_center_view_screen.dart'; @@ -244,6 +245,28 @@ class _UpsellScreenState extends State { }).toList(); return [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Card( + margin: const EdgeInsets.all(8.0), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column(children: [ + const Text("Add-On Purchasing"), + ElevatedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + AddOnPurchasingScreen(offering: offering), + ), + ); + }, + child: const Text('Add-On Purchasing Screen'), + ), + ]))), + ), Padding( padding: const EdgeInsets.symmetric(vertical: 20), child: Card( diff --git a/test/purchases_flutter_test.dart b/test/purchases_flutter_test.dart index 40af21ab0..8013c43a4 100644 --- a/test/purchases_flutter_test.dart +++ b/test/purchases_flutter_test.dart @@ -626,12 +626,27 @@ void main() { '\$199.99', 'USD', ); + const mockStoreProduct2 = StoreProduct( + 'com.revenuecat.sub2', + 'description', + 'lifetime (PurchasesSample)', + 199.99, + '\$199.99', + 'USD', + productCategory: ProductCategory.subscription, + ); const mockPackage = Package( '\$rc_lifetime', PackageType.lifetime, mockStoreProduct, PresentedOfferingContext('main', null, null), ); + const mockAddOnPackage = Package( + '\$rc_monthly', + PackageType.monthly, + mockStoreProduct2, + PresentedOfferingContext('add_on', null, null), + ); const promotionalOffer = PromotionalOffer( 'identifier', 'keyIdentifier', @@ -639,6 +654,21 @@ void main() { 'signature', 1234567890, ); + const mockAddOnSubscriptionOption = SubscriptionOption( + 'add_on_subscription_option_id', + 'com.revenuecat.sub2', + 'com.revenuecat.sub2', + [], + [], + false, + Period(PeriodUnit.month, 1, 'P1M'), + false, + null, + null, + null, + null, + null, + ); final purchaseParams = PurchaseParams.package( mockPackage, googleProductChangeInfo: GoogleProductChangeInfo( @@ -648,6 +678,9 @@ void main() { googleIsPersonalizedPrice: true, promotionalOffer: promotionalOffer, customerEmail: 'testemail@revenuecat.com', + addOnStoreProducts: [mockStoreProduct2], + addOnSubscriptionOptions: [mockAddOnSubscriptionOption], + addOnPackages: [mockAddOnPackage], ); final purchasePackageResult = await Purchases.purchase(purchaseParams); @@ -674,6 +707,28 @@ void main() { 'signedDiscountTimestamp': '1234567890', 'winBackOfferIdentifier': null, 'customerEmail': 'testemail@revenuecat.com', + 'addOnStoreProducts': [ + { + 'productIdentifier': 'com.revenuecat.sub2', + 'type': 'subscription', + }, + ], + 'addOnSubscriptionOptions': [ + { + 'productIdentifier': 'com.revenuecat.sub2', + 'optionIdentifier': 'add_on_subscription_option_id', + }, + ], + 'addOnPackages': [ + { + 'packageIdentifier': '\$rc_monthly', + 'presentedOfferingContext': { + 'offeringIdentifier': 'add_on', + 'placementIdentifier': null, + 'targetingContext': null, + }, + }, + ], }, ), ], @@ -742,6 +797,9 @@ void main() { 'googleIsPersonalizedPrice': null, 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': 'win_back_identifier', + 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': null, + 'addOnPackages': null, 'customerEmail': null, }, ), @@ -802,6 +860,9 @@ void main() { 'googleIsPersonalizedPrice': null, 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': null, + 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': null, + 'addOnPackages': null, 'customerEmail': null, }, ), @@ -872,6 +933,9 @@ void main() { 'googleIsPersonalizedPrice': true, 'signedDiscountTimestamp': '1234567890', 'winBackOfferIdentifier': null, + 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': null, + 'addOnPackages': null, 'customerEmail': 'testemail@revenuecat.com' }, ), @@ -939,6 +1003,9 @@ void main() { 'googleIsPersonalizedPrice': null, 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': 'win_back_identifier', + 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': null, + 'addOnPackages': null, 'customerEmail': null, }, ), @@ -991,6 +1058,9 @@ void main() { 'googleIsPersonalizedPrice': null, 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': null, + 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': null, + 'addOnPackages': null, 'customerEmail': null, }, ), @@ -1058,6 +1128,9 @@ void main() { 'googleIsPersonalizedPrice': true, 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': null, + 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': null, + 'addOnPackages': null, 'customerEmail': 'testemail@revenuecat.com', }, ), @@ -1114,6 +1187,9 @@ void main() { 'googleIsPersonalizedPrice': null, 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': null, + 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': null, + 'addOnPackages': null, 'customerEmail': null, }, ), @@ -2282,4 +2358,816 @@ void main() { expect(virtualCurrencies, isNull); }); + + test('purchase store product with store product add-ons calls purchase successfully', () async { + try { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + response = { + 'productIdentifier': 'product.identifier', + 'customerInfo': mockCustomerInfoResponse, + 'transaction': mockStoreTransaction, + }; + const mockStoreProduct = StoreProduct( + 'com.revenuecat.sub1', + 'description', + 'lifetime (PurchasesSample)', + 199.99, + '\$199.99', + 'USD', + productCategory: ProductCategory.subscription, + presentedOfferingContext: PresentedOfferingContext('main', null, null), + ); + const mockStoreProduct2 = StoreProduct( + 'com.revenuecat.sub2', + 'description', + 'lifetime (PurchasesSample)', + 199.99, + '\$199.99', + 'USD', + productCategory: ProductCategory.subscription, + ); + + const purchaseParams = PurchaseParams.storeProduct( + mockStoreProduct, + addOnStoreProducts: [mockStoreProduct2], + ); + final purchasePackageResult = await Purchases.purchase(purchaseParams); + expect( + purchasePackageResult, + PurchaseResult.fromJson(response), + ); + + expect( + log, + [ + isMethodCall( + 'purchaseProduct', + arguments: { + 'productIdentifier': 'com.revenuecat.sub1', + 'type': 'subscription', + 'presentedOfferingContext': { + 'offeringIdentifier': 'main', + 'placementIdentifier': null, + 'targetingContext': null, + }, + 'googleOldProductIdentifier': null, + 'googleProrationMode': null, + 'googleIsPersonalizedPrice': null, + 'signedDiscountTimestamp': null, + 'winBackOfferIdentifier': null, + 'customerEmail': null, + 'addOnStoreProducts': [ + { + 'productIdentifier': 'com.revenuecat.sub2', + 'type': 'subscription', + }, + ], + 'addOnSubscriptionOptions': null, + 'addOnPackages': null, + }, + ), + ], + ); + } on PlatformException catch (e) { + fail('there was an exception $e'); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + test('purchase store product with packages add-ons calls purchase successfully', () async { + try { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + response = { + 'productIdentifier': 'product.identifier', + 'customerInfo': mockCustomerInfoResponse, + 'transaction': mockStoreTransaction, + }; + const mockStoreProduct = StoreProduct( + 'com.revenuecat.sub1', + 'description', + 'lifetime (PurchasesSample)', + 199.99, + '\$199.99', + 'USD', + productCategory: ProductCategory.subscription, + presentedOfferingContext: PresentedOfferingContext('main', null, null), + ); + const mockAddOnPackageStoreProduct = StoreProduct( + 'com.revenuecat.sub2', + 'description', + 'monthly (PurchasesSample)', + 19.99, + '\$19.99', + 'USD', + productCategory: ProductCategory.subscription, + presentedOfferingContext: PresentedOfferingContext('add_on', null, null), + ); + const mockAddOnPackage = Package( + '\$rc_monthly', + PackageType.monthly, + mockAddOnPackageStoreProduct, + PresentedOfferingContext('add_on', null, null), + ); + + const purchaseParams = PurchaseParams.storeProduct( + mockStoreProduct, + addOnPackages: [mockAddOnPackage], + ); + final purchaseProductResult = await Purchases.purchase(purchaseParams); + expect( + purchaseProductResult, + PurchaseResult.fromJson(response), + ); + + expect( + log, + [ + isMethodCall( + 'purchaseProduct', + arguments: { + 'productIdentifier': 'com.revenuecat.sub1', + 'type': 'subscription', + 'presentedOfferingContext': { + 'offeringIdentifier': 'main', + 'placementIdentifier': null, + 'targetingContext': null, + }, + 'googleOldProductIdentifier': null, + 'googleProrationMode': null, + 'googleIsPersonalizedPrice': null, + 'signedDiscountTimestamp': null, + 'winBackOfferIdentifier': null, + 'customerEmail': null, + 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': null, + 'addOnPackages': [ + { + 'packageIdentifier': '\$rc_monthly', + 'presentedOfferingContext': { + 'offeringIdentifier': 'add_on', + 'placementIdentifier': null, + 'targetingContext': null, + }, + }, + ], + }, + ), + ], + ); + } on PlatformException catch (e) { + fail('there was an exception $e'); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + test('purchase store product with add-on subscription options calls purchase successfully', () async { + try { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + response = { + 'productIdentifier': 'product.identifier', + 'customerInfo': mockCustomerInfoResponse, + 'transaction': mockStoreTransaction, + }; + const mockStoreProduct = StoreProduct( + 'com.revenuecat.sub1', + 'description', + 'lifetime (PurchasesSample)', + 199.99, + '\$199.99', + 'USD', + productCategory: ProductCategory.subscription, + presentedOfferingContext: PresentedOfferingContext('main', null, null), + ); + const mockAddOnSubscriptionOption = SubscriptionOption( + 'add_on_subscription_option_id', + 'com.revenuecat.sub2', + 'com.revenuecat.sub2', + [], + [], + false, + Period(PeriodUnit.month, 1, 'P1M'), + false, + null, + null, + null, + null, + null, + ); + + const purchaseParams = PurchaseParams.storeProduct( + mockStoreProduct, + addOnSubscriptionOptions: [mockAddOnSubscriptionOption], + ); + final purchaseProductResult = await Purchases.purchase(purchaseParams); + expect( + purchaseProductResult, + PurchaseResult.fromJson(response), + ); + + expect( + log, + [ + isMethodCall( + 'purchaseProduct', + arguments: { + 'productIdentifier': 'com.revenuecat.sub1', + 'type': 'subscription', + 'presentedOfferingContext': { + 'offeringIdentifier': 'main', + 'placementIdentifier': null, + 'targetingContext': null, + }, + 'googleOldProductIdentifier': null, + 'googleProrationMode': null, + 'googleIsPersonalizedPrice': null, + 'signedDiscountTimestamp': null, + 'winBackOfferIdentifier': null, + 'customerEmail': null, + 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': [ + { + 'productIdentifier': 'com.revenuecat.sub2', + 'optionIdentifier': 'add_on_subscription_option_id', + }, + ], + 'addOnPackages': null, + }, + ), + ], + ); + } on PlatformException catch (e) { + fail('there was an exception $e'); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + test('purchase subscription option with store product add-ons calls purchase successfully', + () async { + try { + response = { + 'productIdentifier': 'product.identifier', + 'customerInfo': mockCustomerInfoResponse, + 'transaction': mockStoreTransaction, + }; + const presentedOfferingContext = + PresentedOfferingContext('main', null, null); + const mockSubscriptionOption = SubscriptionOption( + 'subscription_option_id', + 'com.revenuecat.monthly', + 'com.revenuecat.monthly', + [], + [], + false, + Period(PeriodUnit.month, 1, 'P1M'), + false, + null, + null, + null, + presentedOfferingContext, + null, + ); + const mockStoreProduct = StoreProduct( + 'com.revenuecat.sub1', + 'description', + 'lifetime (PurchasesSample)', + 199.99, + '\$199.99', + 'USD', + productCategory: ProductCategory.subscription, + presentedOfferingContext: PresentedOfferingContext('main', null, null), + ); + + final purchaseParams = PurchaseParams.subscriptionOption( + mockSubscriptionOption, + googleProductChangeInfo: GoogleProductChangeInfo( + 'old_product_id', + prorationMode: GoogleProrationMode.immediateAndChargeFullPrice, + ), + googleIsPersonalizedPrice: true, + customerEmail: 'testemail@revenuecat.com', + addOnStoreProducts: [mockStoreProduct], + ); + final purchaseSubscriptionOptionResult = + await Purchases.purchase(purchaseParams); + expect( + purchaseSubscriptionOptionResult, + PurchaseResult.fromJson(response), + ); + + expect( + log, + [ + isMethodCall( + 'purchaseSubscriptionOption', + arguments: { + 'productIdentifier': 'com.revenuecat.monthly', + 'optionIdentifier': 'subscription_option_id', + 'presentedOfferingContext': { + 'offeringIdentifier': 'main', + 'placementIdentifier': null, + 'targetingContext': null, + }, + 'googleOldProductIdentifier': 'old_product_id', + 'googleProrationMode': 5, + 'googleIsPersonalizedPrice': true, + 'signedDiscountTimestamp': null, + 'winBackOfferIdentifier': null, + 'customerEmail': 'testemail@revenuecat.com', + 'addOnStoreProducts': [ + { + 'productIdentifier': 'com.revenuecat.sub1', + 'type': 'subscription', + }, + ], + 'addOnSubscriptionOptions': null, + 'addOnPackages': null, + }, + ), + ], + ); + } on PlatformException catch (e) { + fail('there was an exception $e'); + } + }); + + test('purchase subscription option with add-on packages calls purchase successfully', + () async { + try { + response = { + 'productIdentifier': 'product.identifier', + 'customerInfo': mockCustomerInfoResponse, + 'transaction': mockStoreTransaction, + }; + const presentedOfferingContext = + PresentedOfferingContext('main', null, null); + const mockSubscriptionOption = SubscriptionOption( + 'subscription_option_id', + 'com.revenuecat.monthly', + 'com.revenuecat.monthly', + [], + [], + false, + Period(PeriodUnit.month, 1, 'P1M'), + false, + null, + null, + null, + presentedOfferingContext, + null, + ); + const mockAddOnPackageStoreProduct = StoreProduct( + 'com.revenuecat.sub1', + 'description', + 'monthly (PurchasesSample)', + 19.99, + '\$19.99', + 'USD', + productCategory: ProductCategory.subscription, + presentedOfferingContext: PresentedOfferingContext('add_on', null, null), + ); + const mockAddOnPackage = Package( + '\$rc_add_on', + PackageType.monthly, + mockAddOnPackageStoreProduct, + PresentedOfferingContext('add_on', null, null), + ); + + final purchaseParams = PurchaseParams.subscriptionOption( + mockSubscriptionOption, + googleProductChangeInfo: GoogleProductChangeInfo( + 'old_product_id', + prorationMode: GoogleProrationMode.immediateAndChargeFullPrice, + ), + googleIsPersonalizedPrice: true, + customerEmail: 'testemail@revenuecat.com', + addOnPackages: [mockAddOnPackage], + ); + final purchaseSubscriptionOptionResult = + await Purchases.purchase(purchaseParams); + expect( + purchaseSubscriptionOptionResult, + PurchaseResult.fromJson(response), + ); + + expect( + log, + [ + isMethodCall( + 'purchaseSubscriptionOption', + arguments: { + 'productIdentifier': 'com.revenuecat.monthly', + 'optionIdentifier': 'subscription_option_id', + 'presentedOfferingContext': { + 'offeringIdentifier': 'main', + 'placementIdentifier': null, + 'targetingContext': null, + }, + 'googleOldProductIdentifier': 'old_product_id', + 'googleProrationMode': 5, + 'googleIsPersonalizedPrice': true, + 'signedDiscountTimestamp': null, + 'winBackOfferIdentifier': null, + 'customerEmail': 'testemail@revenuecat.com', + 'addOnStoreProducts': null, + 'addOnPackages': [ + { + 'packageIdentifier': '\$rc_add_on', + 'presentedOfferingContext': { + 'offeringIdentifier': 'add_on', + 'placementIdentifier': null, + 'targetingContext': null, + }, + }, + ], + 'addOnSubscriptionOptions': null, + }, + ), + ], + ); + } on PlatformException catch (e) { + fail('there was an exception $e'); + } + }); + + test('purchase subscription option with add-on subscription options calls purchase successfully', + () async { + try { + response = { + 'productIdentifier': 'product.identifier', + 'customerInfo': mockCustomerInfoResponse, + 'transaction': mockStoreTransaction, + }; + const presentedOfferingContext = + PresentedOfferingContext('main', null, null); + const mockSubscriptionOption = SubscriptionOption( + 'subscription_option_id', + 'com.revenuecat.monthly', + 'com.revenuecat.monthly', + [], + [], + false, + Period(PeriodUnit.month, 1, 'P1M'), + false, + null, + null, + null, + presentedOfferingContext, + null, + ); + const mockAddOnSubscriptionOption = SubscriptionOption( + 'add_on_subscription_option_id', + 'com.revenuecat.sub1', + 'com.revenuecat.sub1', + [], + [], + false, + Period(PeriodUnit.month, 1, 'P1M'), + false, + null, + null, + null, + presentedOfferingContext, + null, + ); + + final purchaseParams = PurchaseParams.subscriptionOption( + mockSubscriptionOption, + googleProductChangeInfo: GoogleProductChangeInfo( + 'old_product_id', + prorationMode: GoogleProrationMode.immediateAndChargeFullPrice, + ), + googleIsPersonalizedPrice: true, + customerEmail: 'testemail@revenuecat.com', + addOnSubscriptionOptions: [mockAddOnSubscriptionOption], + ); + final purchaseSubscriptionOptionResult = + await Purchases.purchase(purchaseParams); + expect( + purchaseSubscriptionOptionResult, + PurchaseResult.fromJson(response), + ); + + expect( + log, + [ + isMethodCall( + 'purchaseSubscriptionOption', + arguments: { + 'productIdentifier': 'com.revenuecat.monthly', + 'optionIdentifier': 'subscription_option_id', + 'presentedOfferingContext': { + 'offeringIdentifier': 'main', + 'placementIdentifier': null, + 'targetingContext': null, + }, + 'googleOldProductIdentifier': 'old_product_id', + 'googleProrationMode': 5, + 'googleIsPersonalizedPrice': true, + 'signedDiscountTimestamp': null, + 'winBackOfferIdentifier': null, + 'customerEmail': 'testemail@revenuecat.com', + 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': [ + { + 'productIdentifier': 'com.revenuecat.sub1', + 'optionIdentifier': 'add_on_subscription_option_id', + }, + ], + 'addOnPackages': null, + }, + ), + ], + ); + } on PlatformException catch (e) { + fail('there was an exception $e'); + } + }); + + test('purchase package calls with store product add-ons calls purchase successfully', () async { + try { + response = { + 'productIdentifier': 'product.identifier', + 'customerInfo': mockCustomerInfoResponse, + 'transaction': mockStoreTransaction, + }; + const mockStoreProduct = StoreProduct( + 'com.revenuecat.lifetime', + 'description', + 'lifetime (PurchasesSample)', + 199.99, + '\$199.99', + 'USD', + ); + const mockStoreProduct2 = StoreProduct( + 'com.revenuecat.sub2', + 'description', + 'lifetime (PurchasesSample)', + 199.99, + '\$199.99', + 'USD', + productCategory: ProductCategory.subscription, + presentedOfferingContext: PresentedOfferingContext('main', null, null), + ); + const mockPackage = Package( + '\$rc_lifetime', + PackageType.lifetime, + mockStoreProduct, + PresentedOfferingContext('main', null, null), + ); + const promotionalOffer = PromotionalOffer( + 'identifier', + 'keyIdentifier', + 'nonce', + 'signature', + 1234567890, + ); + final purchaseParams = PurchaseParams.package( + mockPackage, + googleProductChangeInfo: GoogleProductChangeInfo( + 'old_product_id', + prorationMode: GoogleProrationMode.immediateAndChargeFullPrice, + ), + googleIsPersonalizedPrice: true, + promotionalOffer: promotionalOffer, + customerEmail: 'testemail@revenuecat.com', + addOnStoreProducts: [mockStoreProduct2], + ); + final purchasePackageResult = await Purchases.purchase(purchaseParams); + expect( + purchasePackageResult, + PurchaseResult.fromJson(response), + ); + + expect( + log, + [ + isMethodCall( + 'purchasePackage', + arguments: { + 'packageIdentifier': '\$rc_lifetime', + 'presentedOfferingContext': { + 'offeringIdentifier': 'main', + 'placementIdentifier': null, + 'targetingContext': null, + }, + 'googleOldProductIdentifier': 'old_product_id', + 'googleProrationMode': 5, + 'googleIsPersonalizedPrice': true, + 'signedDiscountTimestamp': '1234567890', + 'winBackOfferIdentifier': null, + 'customerEmail': 'testemail@revenuecat.com', + 'addOnStoreProducts': [ + { + 'productIdentifier': 'com.revenuecat.sub2', + 'type': 'subscription', + }, + ], + 'addOnSubscriptionOptions': null, + 'addOnPackages': null, + }, + ), + ], + ); + } on PlatformException catch (e) { + fail('there was an exception $e'); + } + }); + + test('purchase package with add-on packages calls purchase successfully', () async { + try { + response = { + 'productIdentifier': 'product.identifier', + 'customerInfo': mockCustomerInfoResponse, + 'transaction': mockStoreTransaction, + }; + const mockStoreProduct = StoreProduct( + 'com.revenuecat.lifetime', + 'description', + 'lifetime (PurchasesSample)', + 199.99, + '\$199.99', + 'USD', + ); + const mockAddOnPackageProduct = StoreProduct( + 'com.revenuecat.sub2', + 'description', + 'monthly (PurchasesSample)', + 19.99, + '\$19.99', + 'USD', + productCategory: ProductCategory.subscription, + presentedOfferingContext: PresentedOfferingContext('add_on', null, null), + ); + const mockPackage = Package( + '\$rc_lifetime', + PackageType.lifetime, + mockStoreProduct, + PresentedOfferingContext('main', null, null), + ); + const mockAddOnPackage = Package( + '\$rc_add_on', + PackageType.monthly, + mockAddOnPackageProduct, + PresentedOfferingContext('add_on', null, null), + ); + const promotionalOffer = PromotionalOffer( + 'identifier', + 'keyIdentifier', + 'nonce', + 'signature', + 1234567890, + ); + final purchaseParams = PurchaseParams.package( + mockPackage, + googleProductChangeInfo: GoogleProductChangeInfo( + 'old_product_id', + prorationMode: GoogleProrationMode.immediateAndChargeFullPrice, + ), + googleIsPersonalizedPrice: true, + promotionalOffer: promotionalOffer, + customerEmail: 'testemail@revenuecat.com', + addOnPackages: [mockAddOnPackage], + ); + final purchasePackageResult = await Purchases.purchase(purchaseParams); + expect( + purchasePackageResult, + PurchaseResult.fromJson(response), + ); + + expect( + log, + [ + isMethodCall( + 'purchasePackage', + arguments: { + 'packageIdentifier': '\$rc_lifetime', + 'presentedOfferingContext': { + 'offeringIdentifier': 'main', + 'placementIdentifier': null, + 'targetingContext': null, + }, + 'googleOldProductIdentifier': 'old_product_id', + 'googleProrationMode': 5, + 'googleIsPersonalizedPrice': true, + 'signedDiscountTimestamp': '1234567890', + 'winBackOfferIdentifier': null, + 'customerEmail': 'testemail@revenuecat.com', + 'addOnStoreProducts': null, + 'addOnPackages': [ + { + 'packageIdentifier': '\$rc_add_on', + 'presentedOfferingContext': { + 'offeringIdentifier': 'add_on', + 'placementIdentifier': null, + 'targetingContext': null, + }, + }, + ], + 'addOnSubscriptionOptions': null, + }, + ), + ], + ); + } on PlatformException catch (e) { + fail('there was an exception $e'); + } + }); + + test('purchase package with add-on subscription options calls purchase successfully', () async { + try { + response = { + 'productIdentifier': 'product.identifier', + 'customerInfo': mockCustomerInfoResponse, + 'transaction': mockStoreTransaction, + }; + const mockStoreProduct = StoreProduct( + 'com.revenuecat.lifetime', + 'description', + 'lifetime (PurchasesSample)', + 199.99, + '\$199.99', + 'USD', + ); + const mockAddOnSubscriptionOption = SubscriptionOption( + 'add_on_subscription_option_id', + 'com.revenuecat.sub2', + 'com.revenuecat.sub2', + [], + [], + false, + Period(PeriodUnit.month, 1, 'P1M'), + false, + null, + null, + null, + PresentedOfferingContext('main', null, null), + null, + ); + const mockPackage = Package( + '\$rc_lifetime', + PackageType.lifetime, + mockStoreProduct, + PresentedOfferingContext('main', null, null), + ); + const promotionalOffer = PromotionalOffer( + 'identifier', + 'keyIdentifier', + 'nonce', + 'signature', + 1234567890, + ); + final purchaseParams = PurchaseParams.package( + mockPackage, + googleProductChangeInfo: GoogleProductChangeInfo( + 'old_product_id', + prorationMode: GoogleProrationMode.immediateAndChargeFullPrice, + ), + googleIsPersonalizedPrice: true, + promotionalOffer: promotionalOffer, + customerEmail: 'testemail@revenuecat.com', + addOnSubscriptionOptions: [mockAddOnSubscriptionOption], + ); + final purchasePackageResult = await Purchases.purchase(purchaseParams); + expect( + purchasePackageResult, + PurchaseResult.fromJson(response), + ); + + expect( + log, + [ + isMethodCall( + 'purchasePackage', + arguments: { + 'packageIdentifier': '\$rc_lifetime', + 'presentedOfferingContext': { + 'offeringIdentifier': 'main', + 'placementIdentifier': null, + 'targetingContext': null, + }, + 'googleOldProductIdentifier': 'old_product_id', + 'googleProrationMode': 5, + 'googleIsPersonalizedPrice': true, + 'signedDiscountTimestamp': '1234567890', + 'winBackOfferIdentifier': null, + 'customerEmail': 'testemail@revenuecat.com', + 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': [ + { + 'productIdentifier': 'com.revenuecat.sub2', + 'optionIdentifier': 'add_on_subscription_option_id', + }, + ], + 'addOnPackages': null, + }, + ), + ], + ); + } on PlatformException catch (e) { + fail('there was an exception $e'); + } + }); }