From 9112ef54b643ea1e790db4e5cf3d11b0c2049e67 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Tue, 4 Nov 2025 11:18:16 -0600 Subject: [PATCH 1/4] add addOnStoreProduts to Purchase Params (#1514) --- .../PurchasesFlutterPlugin.java | 7 +- .../models/purchase_params_api_test.dart | 32 +- lib/models/purchase_params.dart | 14 + lib/purchases_flutter.dart | 8 + .../lib/src/add_on_purchasing_screen.dart | 332 ++++++++++++++++++ .../purchase_tester/lib/src/upsell.dart | 23 ++ test/purchases_flutter_test.dart | 284 +++++++++++++++ 7 files changed, 697 insertions(+), 3 deletions(-) create mode 100644 revenuecat_examples/purchase_tester/lib/src/add_on_purchasing_screen.dart 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 40513d638..2adc65cc9 100644 --- a/android/src/main/java/com/revenuecat/purchases_flutter/PurchasesFlutterPlugin.java +++ b/android/src/main/java/com/revenuecat/purchases_flutter/PurchasesFlutterPlugin.java @@ -153,8 +153,9 @@ 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"); purchaseProduct(productIdentifier, type, googleOldProductIdentifer, googleProrationMode, - googleIsPersonalizedPrice, presentedOfferingContext, result); + googleIsPersonalizedPrice, presentedOfferingContext, addOnStoreProducts, result); break; case "purchasePackage": String packageIdentifier = call.argument("packageIdentifier"); @@ -464,6 +465,7 @@ private void purchaseProduct(final String productIdentifier, @Nullable final Integer googleProrationMode, @Nullable final Boolean googleIsPersonalizedPrice, @Nullable final Map presentedOfferingContext, + @Nullable final List> addOnStoreProducts, final Result result) { CommonKt.purchaseProduct( activity, @@ -474,7 +476,8 @@ private void purchaseProduct(final String productIdentifier, googleProrationMode, googleIsPersonalizedPrice, presentedOfferingContext, - getOnResult(result)); + getOnResult(result), + addOnStoreProducts); } private void purchasePackage(final String packageIdentifier, 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..e581dc614 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,7 @@ class _PurchaseParamsApiTest { PromotionalOffer? promotionalOffer, WinBackOffer? winBackOffer, String? customerEmail, + List? addOnStoreProducts, ) { PurchaseParams purchaseParams = PurchaseParams.package( package, @@ -45,6 +46,15 @@ class _PurchaseParamsApiTest { winBackOffer: winBackOffer, customerEmail: customerEmail, ); + purchaseParams = PurchaseParams.package( + package, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + promotionalOffer: promotionalOffer, + winBackOffer: winBackOffer, + customerEmail: customerEmail, + addOnStoreProducts: addOnStoreProducts, + ); } void _checkStoreProductConstructor( @@ -54,6 +64,7 @@ class _PurchaseParamsApiTest { PromotionalOffer? promotionalOffer, WinBackOffer? winBackOffer, String? customerEmail, + List? addOnStoreProducts, ) { PurchaseParams purchaseParams = PurchaseParams.storeProduct( storeProduct, @@ -88,6 +99,15 @@ class _PurchaseParamsApiTest { winBackOffer: winBackOffer, customerEmail: customerEmail, ); + purchaseParams = PurchaseParams.storeProduct( + storeProduct, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + promotionalOffer: promotionalOffer, + winBackOffer: winBackOffer, + customerEmail: customerEmail, + addOnStoreProducts: addOnStoreProducts, + ); } void _checkSubscriptionOptionConstructor( @@ -95,6 +115,7 @@ class _PurchaseParamsApiTest { GoogleProductChangeInfo? googleProductChangeInfo, bool? googleIsPersonalizedPrice, String? customerEmail, + List? addOnStoreProducts, ) { PurchaseParams purchaseParams = PurchaseParams.subscriptionOption( subscriptionOption, @@ -113,7 +134,15 @@ class _PurchaseParamsApiTest { googleProductChangeInfo: googleProductChangeInfo, googleIsPersonalizedPrice: googleIsPersonalizedPrice, customerEmail: customerEmail, - );} + ); + purchaseParams = PurchaseParams.subscriptionOption( + subscriptionOption, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + customerEmail: customerEmail, + addOnStoreProducts: addOnStoreProducts, + ); + } void _checkProperties(PurchaseParams purchaseParams) { Package? package = purchaseParams.package; @@ -124,5 +153,6 @@ class _PurchaseParamsApiTest { PromotionalOffer? promotionalOffer = purchaseParams.promotionalOffer; WinBackOffer? winBackOffer = purchaseParams.winBackOffer; String? customerEmail = purchaseParams.customerEmail; + List? addOnStoreProducts = purchaseParams.addOnStoreProducts; } } diff --git a/lib/models/purchase_params.dart b/lib/models/purchase_params.dart index eeb51be2a..ca9b819ba 100644 --- a/lib/models/purchase_params.dart +++ b/lib/models/purchase_params.dart @@ -15,6 +15,7 @@ class PurchaseParams { final PromotionalOffer? promotionalOffer; final WinBackOffer? winBackOffer; final String? customerEmail; + final List? addOnStoreProducts; const PurchaseParams._( this.package, @@ -25,6 +26,7 @@ class PurchaseParams { this.promotionalOffer, this.winBackOffer, this.customerEmail, + this.addOnStoreProducts, ); /// Creates purchase parameters for a package. @@ -52,6 +54,8 @@ 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 alongside the package. + /// const PurchaseParams.package( Package package, { GoogleProductChangeInfo? googleProductChangeInfo, @@ -59,6 +63,7 @@ class PurchaseParams { PromotionalOffer? promotionalOffer, WinBackOffer? winBackOffer, String? customerEmail, + List? addOnStoreProducts, }) : this._( package, null, @@ -68,6 +73,7 @@ class PurchaseParams { promotionalOffer, winBackOffer, customerEmail, + addOnStoreProducts, ); /// Creates purchase parameters for a store product. @@ -96,6 +102,8 @@ 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 alongside the main product. + /// const PurchaseParams.storeProduct( StoreProduct storeProduct, { GoogleProductChangeInfo? googleProductChangeInfo, @@ -103,6 +111,7 @@ class PurchaseParams { PromotionalOffer? promotionalOffer, WinBackOffer? winBackOffer, String? customerEmail, + List? addOnStoreProducts, }) : this._( null, storeProduct, @@ -112,6 +121,7 @@ class PurchaseParams { promotionalOffer, winBackOffer, customerEmail, + addOnStoreProducts, ); /// Creates purchase parameters for a subscription option. Google Play-only. @@ -131,11 +141,14 @@ 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 alongside the main subscription option. + /// const PurchaseParams.subscriptionOption( SubscriptionOption subscriptionOption, { GoogleProductChangeInfo? googleProductChangeInfo, bool? googleIsPersonalizedPrice, String? customerEmail, + List? addOnStoreProducts, }) : this._( null, null, @@ -145,5 +158,6 @@ class PurchaseParams { null, null, customerEmail, + addOnStoreProducts, ); } diff --git a/lib/purchases_flutter.dart b/lib/purchases_flutter.dart index a92f7d323..9121bc795 100644 --- a/lib/purchases_flutter.dart +++ b/lib/purchases_flutter.dart @@ -576,6 +576,13 @@ 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, + 'presentedOfferingContext': storeProduct.presentedOfferingContext?.toJson(), + },) + .toList(); final purchaseArgs = { 'googleOldProductIdentifier': googleProductChangeInfo?.oldProductIdentifier, 'googleProrationMode': prorationMode, @@ -584,6 +591,7 @@ class Purchases { 'presentedOfferingContext': presentedOfferingContextJson, 'customerEmail': customerEmail, 'winBackOfferIdentifier': winBackOffer?.identifier, + 'addOnStoreProducts': addOnStoreProducts, }; 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..f75134684 --- /dev/null +++ b/revenuecat_examples/purchase_tester/lib/src/add_on_purchasing_screen.dart @@ -0,0 +1,332 @@ +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 = {}; + _AddOnType _selectedType = _AddOnType.addOnStoreProducts; + String? _purchaseStatusMessage; + bool _isPurchasing = false; + + bool get _canAttemptPurchase => + !_isPurchasing && + _selectedBaseOptionId != null && + _selectedPurchaseOptionIds.isNotEmpty; + + @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 with PurchaseParams.storeProduct'), + ), + 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), + Text( + 'Add-on type', + style: Theme.of(context).textTheme.titleSmall, + ), + RadioListTile<_AddOnType>( + contentPadding: EdgeInsets.zero, + title: const Text('.addOnStoreProducts()'), + value: _AddOnType.addOnStoreProducts, + groupValue: _selectedType, + onChanged: (value) { + if (value == null) return; + setState(() { + _selectedType = value; + }); + }, + ), + const SizedBox(height: 16), + 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); + + 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), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } + + 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); + } + }); + } + + Future _onPurchasePressed() async { + final baseId = _selectedBaseOptionId; + if (baseId == null) return; + + final baseEntry = _findEntry(baseId); + if (baseEntry == null) return; + + List? addOnStoreProducts; + if (_selectedType == _AddOnType.addOnStoreProducts && + _selectedPurchaseOptionIds.isNotEmpty) { + final Map addOnMap = {}; + for (final entry in _options) { + if (entry.option.id == baseId) continue; + if (_selectedPurchaseOptionIds.contains(entry.option.id)) { + addOnMap[entry.storeProduct.identifier] = entry.storeProduct; + } + } + if (addOnMap.isNotEmpty) { + addOnStoreProducts = addOnMap.values.toList(growable: false); + } + } + + final params = PurchaseParams.storeProduct( + baseEntry.storeProduct, + addOnStoreProducts: addOnStoreProducts, + ); + + final addOnIdentifiers = addOnStoreProducts + ?.map((product) => product.identifier) + .toList(growable: false); + + final attemptMessage = + 'Attempting purchase: ${baseEntry.storeProduct.identifier}' + '${addOnIdentifiers != null && addOnIdentifiers.isNotEmpty ? ' with add-ons ${addOnIdentifiers.join(', ')}' : ''}'; + debugPrint(attemptMessage); + + try { + setState(() { + _isPurchasing = true; + _purchaseStatusMessage = null; + }); + final result = await Purchases.purchase(params); + final successMessage = + 'Purchase successful: ${result.storeTransaction.productIdentifier}' + '${addOnIdentifiers != null && addOnIdentifiers.isNotEmpty ? ' with add-ons ${addOnIdentifiers.join(', ')}' : ''}'; + 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'; + }); + } + } + + _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, + ), + ); + } + } + return deduped.values.toList() + ..sort((a, b) => a.option.id.compareTo(b.option.id)); + } +} + +class _SubscriptionOptionEntry { + final SubscriptionOption option; + final StoreProduct storeProduct; + + _SubscriptionOptionEntry({ + required this.option, + required this.storeProduct, + }); +} + +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), + ], + ), + ); + } +} + +enum _AddOnType { addOnStoreProducts } + +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 c3fb6cfb1..bebcefdef 100644 --- a/test/purchases_flutter_test.dart +++ b/test/purchases_flutter_test.dart @@ -618,6 +618,15 @@ 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, @@ -640,6 +649,7 @@ void main() { googleIsPersonalizedPrice: true, promotionalOffer: promotionalOffer, customerEmail: 'testemail@revenuecat.com', + addOnStoreProducts: [mockStoreProduct2], ); final purchasePackageResult = await Purchases.purchase(purchaseParams); @@ -666,6 +676,13 @@ void main() { 'signedDiscountTimestamp': '1234567890', 'winBackOfferIdentifier': null, 'customerEmail': 'testemail@revenuecat.com', + 'addOnStoreProducts': [ + { + 'productIdentifier': 'com.revenuecat.sub2', + 'type': 'subscription', + 'presentedOfferingContext': null, + }, + ], }, ), ], @@ -734,6 +751,7 @@ void main() { 'googleIsPersonalizedPrice': null, 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': 'win_back_identifier', + 'addOnStoreProducts': null, 'customerEmail': null, }, ), @@ -794,6 +812,7 @@ void main() { 'googleIsPersonalizedPrice': null, 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': null, + 'addOnStoreProducts': null, 'customerEmail': null, }, ), @@ -864,6 +883,7 @@ void main() { 'googleIsPersonalizedPrice': true, 'signedDiscountTimestamp': '1234567890', 'winBackOfferIdentifier': null, + 'addOnStoreProducts': null, 'customerEmail': 'testemail@revenuecat.com' }, ), @@ -931,6 +951,7 @@ void main() { 'googleIsPersonalizedPrice': null, 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': 'win_back_identifier', + 'addOnStoreProducts': null, 'customerEmail': null, }, ), @@ -983,6 +1004,7 @@ void main() { 'googleIsPersonalizedPrice': null, 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': null, + 'addOnStoreProducts': null, 'customerEmail': null, }, ), @@ -1050,6 +1072,7 @@ void main() { 'googleIsPersonalizedPrice': true, 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': null, + 'addOnStoreProducts': null, 'customerEmail': 'testemail@revenuecat.com', }, ), @@ -1106,6 +1129,7 @@ void main() { 'googleIsPersonalizedPrice': null, 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': null, + 'addOnStoreProducts': null, 'customerEmail': null, }, ), @@ -2174,4 +2198,264 @@ 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', + 'presentedOfferingContext': 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', + 'presentedOfferingContext': { + 'offeringIdentifier': 'main', + 'placementIdentifier': null, + 'targetingContext': 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', + 'presentedOfferingContext': { + 'offeringIdentifier': 'main', + 'placementIdentifier': null, + 'targetingContext': null, + }, + }, + ], + }, + ), + ], + ); + } on PlatformException catch (e) { + fail('there was an exception $e'); + } + }); } From f8251b91b4c8b346dfb509a5584f82ea7a0e72b2 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Wed, 5 Nov 2025 06:30:58 -0600 Subject: [PATCH 2/4] Support addOnSubscriptionOptions in PurchaseParams (#1523) --- .../PurchasesFlutterPlugin.java | 30 +- .../models/purchase_params_api_test.dart | 32 ++ lib/models/purchase_params.dart | 20 +- lib/purchases_flutter.dart | 8 +- .../lib/src/add_on_purchasing_screen.dart | 179 +++++++--- test/purchases_flutter_test.dart | 312 +++++++++++++++++- 6 files changed, 511 insertions(+), 70 deletions(-) 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 2adc65cc9..9cf8cc342 100644 --- a/android/src/main/java/com/revenuecat/purchases_flutter/PurchasesFlutterPlugin.java +++ b/android/src/main/java/com/revenuecat/purchases_flutter/PurchasesFlutterPlugin.java @@ -154,8 +154,10 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { type = call.argument("type"); Map presentedOfferingContext = call.argument("presentedOfferingContext"); List> addOnStoreProducts = call.argument("addOnStoreProducts"); + List> addOnSubscriptionOptions = call.argument("addOnSubscriptionOptions"); purchaseProduct(productIdentifier, type, googleOldProductIdentifer, googleProrationMode, - googleIsPersonalizedPrice, presentedOfferingContext, addOnStoreProducts, result); + googleIsPersonalizedPrice, presentedOfferingContext, addOnStoreProducts, + addOnSubscriptionOptions, result); break; case "purchasePackage": String packageIdentifier = call.argument("packageIdentifier"); @@ -163,8 +165,11 @@ 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"); purchasePackage(packageIdentifier, presentedOfferingContext, googleOldProductIdentifer, - googleProrationMode, googleIsPersonalizedPrice, result); + googleProrationMode, googleIsPersonalizedPrice, addOnStoreProducts, + addOnSubscriptionOptions, result); break; case "purchaseSubscriptionOption": productIdentifier = call.argument("productIdentifier"); @@ -173,8 +178,11 @@ 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"); purchaseSubscriptionOption(productIdentifier, optionIdentifier, googleOldProductIdentifer, - googleProrationMode, googleIsPersonalizedPrice, presentedOfferingContext, result); + googleProrationMode, googleIsPersonalizedPrice, presentedOfferingContext, + addOnStoreProducts, addOnSubscriptionOptions, result); break; case "getAppUserID": getAppUserID(result); @@ -466,6 +474,7 @@ private void purchaseProduct(final String productIdentifier, @Nullable final Boolean googleIsPersonalizedPrice, @Nullable final Map presentedOfferingContext, @Nullable final List> addOnStoreProducts, + @Nullable final List> addOnSubscriptionOptions, final Result result) { CommonKt.purchaseProduct( activity, @@ -477,7 +486,8 @@ private void purchaseProduct(final String productIdentifier, googleIsPersonalizedPrice, presentedOfferingContext, getOnResult(result), - addOnStoreProducts); + addOnStoreProducts, + addOnSubscriptionOptions); } private void purchasePackage(final String packageIdentifier, @@ -485,6 +495,8 @@ 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, final Result result) { CommonKt.purchasePackage( activity, @@ -493,7 +505,9 @@ private void purchasePackage(final String packageIdentifier, googleOldProductId, googleProrationMode, googleIsPersonalizedPrice, - getOnResult(result)); + getOnResult(result), + addOnStoreProducts, + addOnSubscriptionOptions); } private void purchaseSubscriptionOption(final String productIdentifier, @@ -502,6 +516,8 @@ 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, final Result result) { CommonKt.purchaseSubscriptionOption( activity, @@ -511,7 +527,9 @@ private void purchaseSubscriptionOption(final String productIdentifier, googleProrationMode, googleIsPersonalizedPrice, presentedOfferingContext, - getOnResult(result)); + getOnResult(result), + addOnStoreProducts, + addOnSubscriptionOptions); } 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 e581dc614..97ee11693 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 @@ -12,6 +12,7 @@ class _PurchaseParamsApiTest { WinBackOffer? winBackOffer, String? customerEmail, List? addOnStoreProducts, + List? addOnSubscriptionOptions, ) { PurchaseParams purchaseParams = PurchaseParams.package( package, @@ -55,6 +56,16 @@ class _PurchaseParamsApiTest { customerEmail: customerEmail, addOnStoreProducts: addOnStoreProducts, ); + purchaseParams = PurchaseParams.package( + package, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + promotionalOffer: promotionalOffer, + winBackOffer: winBackOffer, + customerEmail: customerEmail, + addOnStoreProducts: addOnStoreProducts, + addOnSubscriptionOptions: addOnSubscriptionOptions, + ); } void _checkStoreProductConstructor( @@ -65,6 +76,7 @@ class _PurchaseParamsApiTest { WinBackOffer? winBackOffer, String? customerEmail, List? addOnStoreProducts, + List? addOnSubscriptionOptions, ) { PurchaseParams purchaseParams = PurchaseParams.storeProduct( storeProduct, @@ -108,6 +120,16 @@ class _PurchaseParamsApiTest { customerEmail: customerEmail, addOnStoreProducts: addOnStoreProducts, ); + purchaseParams = PurchaseParams.storeProduct( + storeProduct, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + promotionalOffer: promotionalOffer, + winBackOffer: winBackOffer, + customerEmail: customerEmail, + addOnStoreProducts: addOnStoreProducts, + addOnSubscriptionOptions: addOnSubscriptionOptions, + ); } void _checkSubscriptionOptionConstructor( @@ -116,6 +138,7 @@ class _PurchaseParamsApiTest { bool? googleIsPersonalizedPrice, String? customerEmail, List? addOnStoreProducts, + List? addOnSubscriptionOptions, ) { PurchaseParams purchaseParams = PurchaseParams.subscriptionOption( subscriptionOption, @@ -142,6 +165,14 @@ class _PurchaseParamsApiTest { customerEmail: customerEmail, addOnStoreProducts: addOnStoreProducts, ); + purchaseParams = PurchaseParams.subscriptionOption( + subscriptionOption, + googleProductChangeInfo: googleProductChangeInfo, + googleIsPersonalizedPrice: googleIsPersonalizedPrice, + customerEmail: customerEmail, + addOnStoreProducts: addOnStoreProducts, + addOnSubscriptionOptions: addOnSubscriptionOptions, + ); } void _checkProperties(PurchaseParams purchaseParams) { @@ -154,5 +185,6 @@ class _PurchaseParamsApiTest { WinBackOffer? winBackOffer = purchaseParams.winBackOffer; String? customerEmail = purchaseParams.customerEmail; List? addOnStoreProducts = purchaseParams.addOnStoreProducts; + List? addOnSubscriptionOptions = purchaseParams.addOnSubscriptionOptions; } } diff --git a/lib/models/purchase_params.dart b/lib/models/purchase_params.dart index ca9b819ba..325c1677a 100644 --- a/lib/models/purchase_params.dart +++ b/lib/models/purchase_params.dart @@ -16,6 +16,7 @@ class PurchaseParams { final WinBackOffer? winBackOffer; final String? customerEmail; final List? addOnStoreProducts; + final List? addOnSubscriptionOptions; const PurchaseParams._( this.package, @@ -27,6 +28,7 @@ class PurchaseParams { this.winBackOffer, this.customerEmail, this.addOnStoreProducts, + this.addOnSubscriptionOptions, ); /// Creates purchase parameters for a package. @@ -54,8 +56,10 @@ 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 alongside the package. + /// [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. + /// const PurchaseParams.package( Package package, { GoogleProductChangeInfo? googleProductChangeInfo, @@ -64,6 +68,7 @@ class PurchaseParams { WinBackOffer? winBackOffer, String? customerEmail, List? addOnStoreProducts, + List? addOnSubscriptionOptions, }) : this._( package, null, @@ -74,6 +79,7 @@ class PurchaseParams { winBackOffer, customerEmail, addOnStoreProducts, + addOnSubscriptionOptions, ); /// Creates purchase parameters for a store product. @@ -102,7 +108,9 @@ 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 alongside the main product. + /// [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. /// const PurchaseParams.storeProduct( StoreProduct storeProduct, { @@ -112,6 +120,7 @@ class PurchaseParams { WinBackOffer? winBackOffer, String? customerEmail, List? addOnStoreProducts, + List? addOnSubscriptionOptions, }) : this._( null, storeProduct, @@ -122,6 +131,7 @@ class PurchaseParams { winBackOffer, customerEmail, addOnStoreProducts, + addOnSubscriptionOptions, ); /// Creates purchase parameters for a subscription option. Google Play-only. @@ -141,7 +151,9 @@ 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 alongside the main subscription option. + /// [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. /// const PurchaseParams.subscriptionOption( SubscriptionOption subscriptionOption, { @@ -149,6 +161,7 @@ class PurchaseParams { bool? googleIsPersonalizedPrice, String? customerEmail, List? addOnStoreProducts, + List? addOnSubscriptionOptions, }) : this._( null, null, @@ -159,5 +172,6 @@ class PurchaseParams { null, customerEmail, addOnStoreProducts, + addOnSubscriptionOptions, ); } diff --git a/lib/purchases_flutter.dart b/lib/purchases_flutter.dart index 9121bc795..6b83069ee 100644 --- a/lib/purchases_flutter.dart +++ b/lib/purchases_flutter.dart @@ -580,7 +580,12 @@ class Purchases { ?.map((storeProduct) => { 'productIdentifier': storeProduct.identifier, 'type': storeProduct.productCategory?.name, - 'presentedOfferingContext': storeProduct.presentedOfferingContext?.toJson(), + },) + .toList(); + final addOnSubscriptionOptions = purchaseParams.addOnSubscriptionOptions + ?.map((subscriptionOption) => { + 'productIdentifier': subscriptionOption.productId, + 'optionIdentifier': subscriptionOption.id, },) .toList(); final purchaseArgs = { @@ -592,6 +597,7 @@ class Purchases { 'customerEmail': customerEmail, 'winBackOfferIdentifier': winBackOffer?.identifier, 'addOnStoreProducts': addOnStoreProducts, + 'addOnSubscriptionOptions': addOnSubscriptionOptions, }; 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 index f75134684..111ea0403 100644 --- a/revenuecat_examples/purchase_tester/lib/src/add_on_purchasing_screen.dart +++ b/revenuecat_examples/purchase_tester/lib/src/add_on_purchasing_screen.dart @@ -15,14 +15,13 @@ class _AddOnPurchasingScreenState extends State { late final List<_SubscriptionOptionEntry> _options; String? _selectedBaseOptionId; final Set _selectedPurchaseOptionIds = {}; - _AddOnType _selectedType = _AddOnType.addOnStoreProducts; + final Map _purchaseAsByOption = + {}; String? _purchaseStatusMessage; bool _isPurchasing = false; bool get _canAttemptPurchase => - !_isPurchasing && - _selectedBaseOptionId != null && - _selectedPurchaseOptionIds.isNotEmpty; + !_isPurchasing && _selectedBaseOptionId != null; @override void initState() { @@ -46,7 +45,7 @@ class _AddOnPurchasingScreenState extends State { const SizedBox(height: 16), ElevatedButton( onPressed: _canAttemptPurchase ? () => _onPurchasePressed() : null, - child: const Text('Purchase with PurchaseParams.storeProduct'), + child: const Text('Purchase'), ), if (_isPurchasing) const Padding( @@ -62,23 +61,6 @@ class _AddOnPurchasingScreenState extends State { ), ), const SizedBox(height: 24), - Text( - 'Add-on type', - style: Theme.of(context).textTheme.titleSmall, - ), - RadioListTile<_AddOnType>( - contentPadding: EdgeInsets.zero, - title: const Text('.addOnStoreProducts()'), - value: _AddOnType.addOnStoreProducts, - groupValue: _selectedType, - onChanged: (value) { - if (value == null) return; - setState(() { - _selectedType = value; - }); - }, - ), - const SizedBox(height: 16), Expanded(child: _buildOptionsList(context)), ], ), @@ -102,6 +84,7 @@ class _AddOnPurchasingScreenState extends State { final bool isBase = _selectedBaseOptionId == option.id; final bool isPurchaseOption = _selectedPurchaseOptionIds.contains(option.id); + final purchaseAs = _purchaseAsFor(option); return Card( child: Padding( @@ -135,18 +118,35 @@ class _AddOnPurchasingScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ _OptionCheckbox( - label: 'Base item', + label: 'Base Item', value: isBase, onChanged: (checked) => _onBaseItemToggled(option, checked), ), if (!isBase) _OptionCheckbox( - label: 'Purchase option', + 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), + ), ], ), ], @@ -178,6 +178,12 @@ class _AddOnPurchasingScreenState extends State { }); } + void _onPurchaseAsChanged(SubscriptionOption option, _PurchaseAs value) { + setState(() { + _purchaseAsByOption[option.id] = value; + }); + } + Future _onPurchasePressed() async { final baseId = _selectedBaseOptionId; if (baseId == null) return; @@ -185,33 +191,70 @@ class _AddOnPurchasingScreenState extends State { final baseEntry = _findEntry(baseId); if (baseEntry == null) return; - List? addOnStoreProducts; - if (_selectedType == _AddOnType.addOnStoreProducts && - _selectedPurchaseOptionIds.isNotEmpty) { - final Map addOnMap = {}; - for (final entry in _options) { - if (entry.option.id == baseId) continue; - if (_selectedPurchaseOptionIds.contains(entry.option.id)) { - addOnMap[entry.storeProduct.identifier] = entry.storeProduct; - } - } - if (addOnMap.isNotEmpty) { - addOnStoreProducts = addOnMap.values.toList(growable: false); + 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 = {}; + + for (final entry in selectedAddOns) { + final selection = _purchaseAsFor(entry.option); + if (selection == _PurchaseAs.storeProduct) { + addOnStoreProductsMap[entry.storeProduct.identifier] = + entry.storeProduct; + } else { + debugPrint('Adding add-on subscription option: ${entry.option.id}: ${entry.option.storeProductId}: ${entry.option.id}'); + addOnSubscriptionOptionsMap[entry.option.id] = entry.option; } } - final params = PurchaseParams.storeProduct( - baseEntry.storeProduct, - addOnStoreProducts: addOnStoreProducts, - ); + final List? addOnStoreProducts = + addOnStoreProductsMap.isNotEmpty + ? addOnStoreProductsMap.values.toList(growable: false) + : null; + final List? addOnSubscriptionOptions = + addOnSubscriptionOptionsMap.isNotEmpty + ? addOnSubscriptionOptionsMap.values.toList(growable: false) + : null; - final addOnIdentifiers = addOnStoreProducts - ?.map((product) => product.identifier) - .toList(growable: false); + final PurchaseParams params; + if (basePurchaseAs == _PurchaseAs.storeProduct) { + params = PurchaseParams.storeProduct( + baseEntry.storeProduct, + addOnStoreProducts: addOnStoreProducts, + addOnSubscriptionOptions: addOnSubscriptionOptions, + ); + } else { + params = PurchaseParams.subscriptionOption( + baseEntry.option, + addOnStoreProducts: addOnStoreProducts, + addOnSubscriptionOptions: addOnSubscriptionOptions, + ); + } + + final baseDescription = basePurchaseAs == _PurchaseAs.storeProduct + ? baseEntry.storeProduct.identifier + : '${baseEntry.option.storeProductId}:${baseEntry.option.id}'; + final addOnDescriptions = [ + if (addOnStoreProducts != null) + ...addOnStoreProducts.map((product) => product.identifier), + if (addOnSubscriptionOptions != null) + ...addOnSubscriptionOptions + .map((option) => '${option.storeProductId}:${option.id}'), + ]; + final purchaseAsLabel = basePurchaseAs == _PurchaseAs.storeProduct + ? 'store product' + : 'subscription option'; + final addOnSummary = + addOnDescriptions.isNotEmpty ? ' with add-ons ${addOnDescriptions.join(', ')}' : ''; final attemptMessage = - 'Attempting purchase: ${baseEntry.storeProduct.identifier}' - '${addOnIdentifiers != null && addOnIdentifiers.isNotEmpty ? ' with add-ons ${addOnIdentifiers.join(', ')}' : ''}'; + 'Attempting purchase: $baseDescription as $purchaseAsLabel$addOnSummary'; debugPrint(attemptMessage); try { @@ -221,8 +264,7 @@ class _AddOnPurchasingScreenState extends State { }); final result = await Purchases.purchase(params); final successMessage = - 'Purchase successful: ${result.storeTransaction.productIdentifier}' - '${addOnIdentifiers != null && addOnIdentifiers.isNotEmpty ? ' with add-ons ${addOnIdentifiers.join(', ')}' : ''}'; + 'Purchase successful: ${result.storeTransaction.productIdentifier}$addOnSummary'; debugPrint(successMessage); setState(() { _isPurchasing = false; @@ -247,6 +289,9 @@ class _AddOnPurchasingScreenState extends State { } } + _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; @@ -315,7 +360,45 @@ class _OptionCheckbox extends StatelessWidget { } } -enum _AddOnType { addOnStoreProducts } +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 } String _formatPricing(SubscriptionOption option) { if (option.pricingPhases.isEmpty) { diff --git a/test/purchases_flutter_test.dart b/test/purchases_flutter_test.dart index bebcefdef..65fcef77c 100644 --- a/test/purchases_flutter_test.dart +++ b/test/purchases_flutter_test.dart @@ -640,6 +640,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( @@ -650,6 +665,7 @@ void main() { promotionalOffer: promotionalOffer, customerEmail: 'testemail@revenuecat.com', addOnStoreProducts: [mockStoreProduct2], + addOnSubscriptionOptions: [mockAddOnSubscriptionOption], ); final purchasePackageResult = await Purchases.purchase(purchaseParams); @@ -680,7 +696,12 @@ void main() { { 'productIdentifier': 'com.revenuecat.sub2', 'type': 'subscription', - 'presentedOfferingContext': null, + }, + ], + 'addOnSubscriptionOptions': [ + { + 'productIdentifier': 'com.revenuecat.sub2', + 'optionIdentifier': 'add_on_subscription_option_id', }, ], }, @@ -752,6 +773,7 @@ void main() { 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': 'win_back_identifier', 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': null, 'customerEmail': null, }, ), @@ -813,6 +835,7 @@ void main() { 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': null, 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': null, 'customerEmail': null, }, ), @@ -884,6 +907,7 @@ void main() { 'signedDiscountTimestamp': '1234567890', 'winBackOfferIdentifier': null, 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': null, 'customerEmail': 'testemail@revenuecat.com' }, ), @@ -952,6 +976,7 @@ void main() { 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': 'win_back_identifier', 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': null, 'customerEmail': null, }, ), @@ -1005,6 +1030,7 @@ void main() { 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': null, 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': null, 'customerEmail': null, }, ), @@ -1073,6 +1099,7 @@ void main() { 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': null, 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': null, 'customerEmail': 'testemail@revenuecat.com', }, ), @@ -1130,6 +1157,7 @@ void main() { 'signedDiscountTimestamp': null, 'winBackOfferIdentifier': null, 'addOnStoreProducts': null, + 'addOnSubscriptionOptions': null, 'customerEmail': null, }, ), @@ -2260,7 +2288,88 @@ void main() { { 'productIdentifier': 'com.revenuecat.sub2', 'type': 'subscription', - 'presentedOfferingContext': null, + }, + ], + 'addOnSubscriptionOptions': 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', }, ], }, @@ -2350,11 +2459,100 @@ void main() { { 'productIdentifier': 'com.revenuecat.sub1', 'type': 'subscription', - 'presentedOfferingContext': { - 'offeringIdentifier': 'main', - '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', }, ], }, @@ -2443,11 +2641,101 @@ void main() { { 'productIdentifier': 'com.revenuecat.sub2', 'type': 'subscription', - 'presentedOfferingContext': { - 'offeringIdentifier': 'main', - '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', }, ], }, From 06c9de5d8b4a361598f67f930c91b5d4a0a91889 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Wed, 5 Nov 2025 07:57:54 -0600 Subject: [PATCH 3/4] clean up purchase tester message --- .../purchase_tester/lib/src/add_on_purchasing_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 111ea0403..559bf757a 100644 --- a/revenuecat_examples/purchase_tester/lib/src/add_on_purchasing_screen.dart +++ b/revenuecat_examples/purchase_tester/lib/src/add_on_purchasing_screen.dart @@ -245,7 +245,7 @@ class _AddOnPurchasingScreenState extends State { ...addOnStoreProducts.map((product) => product.identifier), if (addOnSubscriptionOptions != null) ...addOnSubscriptionOptions - .map((option) => '${option.storeProductId}:${option.id}'), + .map((option) => option.storeProductId), ]; final purchaseAsLabel = basePurchaseAs == _PurchaseAs.storeProduct ? 'store product' From 5646e261b3b4e7c55505f7776bb231d460a413a6 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Fri, 21 Nov 2025 07:56:37 -0600 Subject: [PATCH 4/4] Support Purchasing Packages as Add-Ons (#1545) This PR allows developers using the Flutter SDK to purchase a Play Store subscription with `Package` add-ons by adding `addOnPackages` to the `PurchaseParams` constructors. It also updates the purchase tester's add-ons screen to allow you to select whether each add-on item is purchased as a `Package` This PR is marked to go into the `addOns-dev` branch. Once approved and merged, we'll create a second add-ons beta release from the `addOns-dev` branch. --- .../PurchasesFlutterPlugin.java | 21 +- .../models/purchase_params_api_test.dart | 57 ++++ lib/models/purchase_params.dart | 14 + lib/purchases_flutter.dart | 10 + .../lib/src/add_on_purchasing_screen.dart | 77 +++-- test/purchases_flutter_test.dart | 316 ++++++++++++++++++ 6 files changed, 469 insertions(+), 26 deletions(-) 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 d40147099..36a1f597d 100644 --- a/android/src/main/java/com/revenuecat/purchases_flutter/PurchasesFlutterPlugin.java +++ b/android/src/main/java/com/revenuecat/purchases_flutter/PurchasesFlutterPlugin.java @@ -155,9 +155,10 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { 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, addOnStoreProducts, - addOnSubscriptionOptions, result); + addOnSubscriptionOptions, addOnPackages, result); break; case "purchasePackage": String packageIdentifier = call.argument("packageIdentifier"); @@ -167,9 +168,10 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { googleIsPersonalizedPrice = call.argument("googleIsPersonalizedPrice"); addOnStoreProducts = call.argument("addOnStoreProducts"); addOnSubscriptionOptions = call.argument("addOnSubscriptionOptions"); + addOnPackages = call.argument("addOnPackages"); purchasePackage(packageIdentifier, presentedOfferingContext, googleOldProductIdentifer, googleProrationMode, googleIsPersonalizedPrice, addOnStoreProducts, - addOnSubscriptionOptions, result); + addOnSubscriptionOptions, addOnPackages, result); break; case "purchaseSubscriptionOption": productIdentifier = call.argument("productIdentifier"); @@ -180,9 +182,10 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { presentedOfferingContext = call.argument("presentedOfferingContext"); addOnStoreProducts = call.argument("addOnStoreProducts"); addOnSubscriptionOptions = call.argument("addOnSubscriptionOptions"); + addOnPackages = call.argument("addOnPackages"); purchaseSubscriptionOption(productIdentifier, optionIdentifier, googleOldProductIdentifer, googleProrationMode, googleIsPersonalizedPrice, presentedOfferingContext, - addOnStoreProducts, addOnSubscriptionOptions, result); + addOnStoreProducts, addOnSubscriptionOptions, addOnPackages, result); break; case "getAppUserID": getAppUserID(result); @@ -475,6 +478,7 @@ private void purchaseProduct(final String productIdentifier, @Nullable final Map presentedOfferingContext, @Nullable final List> addOnStoreProducts, @Nullable final List> addOnSubscriptionOptions, + @Nullable final List> addOnPackages, final Result result) { CommonKt.purchaseProduct( activity, @@ -487,7 +491,8 @@ private void purchaseProduct(final String productIdentifier, presentedOfferingContext, getOnResult(result), addOnStoreProducts, - addOnSubscriptionOptions); + addOnSubscriptionOptions, + addOnPackages); } private void purchasePackage(final String packageIdentifier, @@ -497,6 +502,7 @@ private void purchasePackage(final String packageIdentifier, @Nullable final Boolean googleIsPersonalizedPrice, @Nullable final List> addOnStoreProducts, @Nullable final List> addOnSubscriptionOptions, + @Nullable final List> addOnPackages, final Result result) { CommonKt.purchasePackage( activity, @@ -507,7 +513,8 @@ private void purchasePackage(final String packageIdentifier, googleIsPersonalizedPrice, getOnResult(result), addOnStoreProducts, - addOnSubscriptionOptions); + addOnSubscriptionOptions, + addOnPackages); } private void purchaseSubscriptionOption(final String productIdentifier, @@ -518,6 +525,7 @@ private void purchaseSubscriptionOption(final String productIdentifier, @Nullable final Map presentedOfferingContext, @Nullable final List> addOnStoreProducts, @Nullable final List> addOnSubscriptionOptions, + @Nullable final List> addOnPackages, final Result result) { CommonKt.purchaseSubscriptionOption( activity, @@ -529,7 +537,8 @@ private void purchaseSubscriptionOption(final String productIdentifier, presentedOfferingContext, getOnResult(result), addOnStoreProducts, - addOnSubscriptionOptions); + 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 97ee11693..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 @@ -13,6 +13,7 @@ class _PurchaseParamsApiTest { String? customerEmail, List? addOnStoreProducts, List? addOnSubscriptionOptions, + List? addOnPackages, ) { PurchaseParams purchaseParams = PurchaseParams.package( package, @@ -56,6 +57,24 @@ class _PurchaseParamsApiTest { 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, @@ -65,6 +84,7 @@ class _PurchaseParamsApiTest { customerEmail: customerEmail, addOnStoreProducts: addOnStoreProducts, addOnSubscriptionOptions: addOnSubscriptionOptions, + addOnPackages: addOnPackages, ); } @@ -77,6 +97,7 @@ class _PurchaseParamsApiTest { String? customerEmail, List? addOnStoreProducts, List? addOnSubscriptionOptions, + List? addOnPackages, ) { PurchaseParams purchaseParams = PurchaseParams.storeProduct( storeProduct, @@ -120,6 +141,24 @@ class _PurchaseParamsApiTest { 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, @@ -129,6 +168,7 @@ class _PurchaseParamsApiTest { customerEmail: customerEmail, addOnStoreProducts: addOnStoreProducts, addOnSubscriptionOptions: addOnSubscriptionOptions, + addOnPackages: addOnPackages, ); } @@ -139,6 +179,7 @@ class _PurchaseParamsApiTest { String? customerEmail, List? addOnStoreProducts, List? addOnSubscriptionOptions, + List? addOnPackages, ) { PurchaseParams purchaseParams = PurchaseParams.subscriptionOption( subscriptionOption, @@ -165,6 +206,20 @@ class _PurchaseParamsApiTest { 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, @@ -172,6 +227,7 @@ class _PurchaseParamsApiTest { customerEmail: customerEmail, addOnStoreProducts: addOnStoreProducts, addOnSubscriptionOptions: addOnSubscriptionOptions, + addOnPackages: addOnPackages, ); } @@ -186,5 +242,6 @@ class _PurchaseParamsApiTest { 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 325c1677a..26a76438e 100644 --- a/lib/models/purchase_params.dart +++ b/lib/models/purchase_params.dart @@ -17,6 +17,7 @@ class PurchaseParams { final String? customerEmail; final List? addOnStoreProducts; final List? addOnSubscriptionOptions; + final List? addOnPackages; const PurchaseParams._( this.package, @@ -29,6 +30,7 @@ class PurchaseParams { this.customerEmail, this.addOnStoreProducts, this.addOnSubscriptionOptions, + this.addOnPackages, ); /// Creates purchase parameters for a package. @@ -60,6 +62,8 @@ class PurchaseParams { /// /// [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, @@ -69,6 +73,7 @@ class PurchaseParams { String? customerEmail, List? addOnStoreProducts, List? addOnSubscriptionOptions, + List? addOnPackages, }) : this._( package, null, @@ -80,6 +85,7 @@ class PurchaseParams { customerEmail, addOnStoreProducts, addOnSubscriptionOptions, + addOnPackages, ); /// Creates purchase parameters for a store product. @@ -111,6 +117,8 @@ class PurchaseParams { /// [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, { @@ -121,6 +129,7 @@ class PurchaseParams { String? customerEmail, List? addOnStoreProducts, List? addOnSubscriptionOptions, + List? addOnPackages, }) : this._( null, storeProduct, @@ -132,6 +141,7 @@ class PurchaseParams { customerEmail, addOnStoreProducts, addOnSubscriptionOptions, + addOnPackages, ); /// Creates purchase parameters for a subscription option. Google Play-only. @@ -154,6 +164,8 @@ class PurchaseParams { /// [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, { @@ -162,6 +174,7 @@ class PurchaseParams { String? customerEmail, List? addOnStoreProducts, List? addOnSubscriptionOptions, + List? addOnPackages, }) : this._( null, null, @@ -173,5 +186,6 @@ class PurchaseParams { customerEmail, addOnStoreProducts, addOnSubscriptionOptions, + addOnPackages, ); } diff --git a/lib/purchases_flutter.dart b/lib/purchases_flutter.dart index 6b83069ee..3f605cce1 100644 --- a/lib/purchases_flutter.dart +++ b/lib/purchases_flutter.dart @@ -588,6 +588,15 @@ class Purchases { '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, @@ -598,6 +607,7 @@ class Purchases { '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 index 559bf757a..807cc28fa 100644 --- a/revenuecat_examples/purchase_tester/lib/src/add_on_purchasing_screen.dart +++ b/revenuecat_examples/purchase_tester/lib/src/add_on_purchasing_screen.dart @@ -147,6 +147,12 @@ class _AddOnPurchasingScreenState extends State { groupValue: purchaseAs, onChanged: (value) => _onPurchaseAsChanged(option, value), ), + _PurchaseAsPicker( + label: 'Package', + value: _PurchaseAs.package, + groupValue: purchaseAs, + onChanged: (value) => _onPurchaseAsChanged(option, value), + ), ], ), ], @@ -201,15 +207,19 @@ class _AddOnPurchasingScreenState extends State { 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 { - debugPrint('Adding add-on subscription option: ${entry.option.id}: ${entry.option.storeProductId}: ${entry.option.id}'); + } 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; } } @@ -221,38 +231,61 @@ class _AddOnPurchasingScreenState extends State { addOnSubscriptionOptionsMap.isNotEmpty ? addOnSubscriptionOptionsMap.values.toList(growable: false) : null; + final List? addOnPackages = addOnPackagesMap.isNotEmpty + ? addOnPackagesMap.values.toList(growable: false) + : null; final PurchaseParams params; - if (basePurchaseAs == _PurchaseAs.storeProduct) { - params = PurchaseParams.storeProduct( - baseEntry.storeProduct, - addOnStoreProducts: addOnStoreProducts, - addOnSubscriptionOptions: addOnSubscriptionOptions, - ); - } else { - params = PurchaseParams.subscriptionOption( - baseEntry.option, - addOnStoreProducts: addOnStoreProducts, - addOnSubscriptionOptions: addOnSubscriptionOptions, - ); + 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 - : '${baseEntry.option.storeProductId}:${baseEntry.option.id}'; + : 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), + ...addOnSubscriptionOptions.map((option) => option.storeProductId), + if (addOnPackages != null) + ...addOnPackages.map((pkg) => '${pkg.identifier} (package)'), ]; final purchaseAsLabel = basePurchaseAs == _PurchaseAs.storeProduct ? 'store product' - : 'subscription option'; + : basePurchaseAs == _PurchaseAs.subscriptionOption + ? 'subscription option' + : 'package'; final addOnSummary = - addOnDescriptions.isNotEmpty ? ' with add-ons ${addOnDescriptions.join(', ')}' : ''; + addOnDescriptions.isNotEmpty + ? ' with add-ons ${addOnDescriptions.join(', ')}' + : ''; final attemptMessage = 'Attempting purchase: $baseDescription as $purchaseAsLabel$addOnSummary'; debugPrint(attemptMessage); @@ -312,6 +345,7 @@ class _AddOnPurchasingScreenState extends State { () => _SubscriptionOptionEntry( option: option, storeProduct: storeProduct, + package: package, ), ); } @@ -325,9 +359,12 @@ class _SubscriptionOptionEntry { final SubscriptionOption option; final StoreProduct storeProduct; + final Package package; + _SubscriptionOptionEntry({ required this.option, required this.storeProduct, + required this.package, }); } @@ -398,7 +435,7 @@ class _PurchaseAsPicker extends StatelessWidget { } } -enum _PurchaseAs { storeProduct, subscriptionOption } +enum _PurchaseAs { storeProduct, subscriptionOption, package } String _formatPricing(SubscriptionOption option) { if (option.pricingPhases.isEmpty) { diff --git a/test/purchases_flutter_test.dart b/test/purchases_flutter_test.dart index 65fcef77c..9985a424f 100644 --- a/test/purchases_flutter_test.dart +++ b/test/purchases_flutter_test.dart @@ -633,6 +633,12 @@ void main() { mockStoreProduct, PresentedOfferingContext('main', null, null), ); + const mockAddOnPackage = Package( + '\$rc_monthly', + PackageType.monthly, + mockStoreProduct2, + PresentedOfferingContext('add_on', null, null), + ); const promotionalOffer = PromotionalOffer( 'identifier', 'keyIdentifier', @@ -666,6 +672,7 @@ void main() { customerEmail: 'testemail@revenuecat.com', addOnStoreProducts: [mockStoreProduct2], addOnSubscriptionOptions: [mockAddOnSubscriptionOption], + addOnPackages: [mockAddOnPackage], ); final purchasePackageResult = await Purchases.purchase(purchaseParams); @@ -704,6 +711,16 @@ void main() { 'optionIdentifier': 'add_on_subscription_option_id', }, ], + 'addOnPackages': [ + { + 'packageIdentifier': '\$rc_monthly', + 'presentedOfferingContext': { + 'offeringIdentifier': 'add_on', + 'placementIdentifier': null, + 'targetingContext': null, + }, + }, + ], }, ), ], @@ -774,6 +791,7 @@ void main() { 'winBackOfferIdentifier': 'win_back_identifier', 'addOnStoreProducts': null, 'addOnSubscriptionOptions': null, + 'addOnPackages': null, 'customerEmail': null, }, ), @@ -836,6 +854,7 @@ void main() { 'winBackOfferIdentifier': null, 'addOnStoreProducts': null, 'addOnSubscriptionOptions': null, + 'addOnPackages': null, 'customerEmail': null, }, ), @@ -908,6 +927,7 @@ void main() { 'winBackOfferIdentifier': null, 'addOnStoreProducts': null, 'addOnSubscriptionOptions': null, + 'addOnPackages': null, 'customerEmail': 'testemail@revenuecat.com' }, ), @@ -977,6 +997,7 @@ void main() { 'winBackOfferIdentifier': 'win_back_identifier', 'addOnStoreProducts': null, 'addOnSubscriptionOptions': null, + 'addOnPackages': null, 'customerEmail': null, }, ), @@ -1031,6 +1052,7 @@ void main() { 'winBackOfferIdentifier': null, 'addOnStoreProducts': null, 'addOnSubscriptionOptions': null, + 'addOnPackages': null, 'customerEmail': null, }, ), @@ -1100,6 +1122,7 @@ void main() { 'winBackOfferIdentifier': null, 'addOnStoreProducts': null, 'addOnSubscriptionOptions': null, + 'addOnPackages': null, 'customerEmail': 'testemail@revenuecat.com', }, ), @@ -1158,6 +1181,7 @@ void main() { 'winBackOfferIdentifier': null, 'addOnStoreProducts': null, 'addOnSubscriptionOptions': null, + 'addOnPackages': null, 'customerEmail': null, }, ), @@ -2291,6 +2315,94 @@ void main() { }, ], '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, + }, + }, + ], }, ), ], @@ -2372,6 +2484,7 @@ void main() { 'optionIdentifier': 'add_on_subscription_option_id', }, ], + 'addOnPackages': null, }, ), ], @@ -2462,6 +2575,106 @@ void main() { }, ], '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, }, ), ], @@ -2555,6 +2768,7 @@ void main() { 'optionIdentifier': 'add_on_subscription_option_id', }, ], + 'addOnPackages': null, }, ), ], @@ -2644,6 +2858,107 @@ void main() { }, ], '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, }, ), ], @@ -2738,6 +3053,7 @@ void main() { 'optionIdentifier': 'add_on_subscription_option_id', }, ], + 'addOnPackages': null, }, ), ],