diff --git a/docs/blog/2026-02-12-8.2.7-subscription-offers-fix.md b/docs/blog/2026-02-12-8.2.7-subscription-offers-fix.md new file mode 100644 index 000000000..731a89d4c --- /dev/null +++ b/docs/blog/2026-02-12-8.2.7-subscription-offers-fix.md @@ -0,0 +1,49 @@ +--- +slug: 8.2.7 +title: "8.2.7 - iOS subscriptionOffers Fix" +authors: [hyochan] +tags: [release, bug-fix] +--- + +# 8.2.7 + +This release fixes a bug where `subscriptionOffers` was always null on iOS subscription products. + +## Bug Fixes + +### subscriptionOffers Parsing for iOS + +Previously, `ProductSubscriptionIOS.subscriptionOffers` was always null even though the native OpenIAP layer returned the data correctly. This has been fixed by adding proper parsing logic in the Dart layer. + +```dart +final subscriptions = await iap.fetchProducts( + skus: ['premium_monthly'], + type: ProductQueryType.Subs, +); + +for (final sub in subscriptions) { + if (sub is ProductSubscriptionIOS) { + // Now correctly populated + final offers = sub.subscriptionOffers; + if (offers != null) { + for (final offer in offers) { + print('Offer: ${offer.id}'); + print('Type: ${offer.type}'); // introductory, promotional + print('Payment Mode: ${offer.paymentMode}'); // freeTrial, payAsYouGo, etc. + print('Price: ${offer.displayPrice}'); + } + } + } +} +``` + +## Installation + +```yaml +dependencies: + flutter_inapp_purchase: ^8.2.7 +``` + +## Related + +- [Issue #616](https://github.com/hyochan/flutter_inapp_purchase/issues/616) diff --git a/lib/helpers.dart b/lib/helpers.dart index 35c53485e..d2dac1b3a 100644 --- a/lib/helpers.dart +++ b/lib/helpers.dart @@ -121,6 +121,9 @@ gentype.ProductCommon parseProductFromNative( subscriptionInfoIOS: _parseSubscriptionInfoIOS( json['subscriptionInfoIOS'] ?? json['subscription'], ), + subscriptionOffers: _parseSubscriptionOffersIOS( + json['subscriptionOffers'], + ), subscriptionPeriodNumberIOS: json['subscriptionPeriodNumberIOS']?.toString(), subscriptionPeriodUnitIOS: _parseSubscriptionPeriod( @@ -463,6 +466,20 @@ List extractPurchases( // Private helper functions -------------------------------------------------- +/// Safe double parsing that handles both num and String inputs. +double? _toDouble(dynamic value) { + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; +} + +/// Safe int parsing that handles both num and String inputs. +int? _toInt(dynamic value) { + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; +} + gentype.ProductType _parseProductType(dynamic value) { if (value is gentype.ProductType) return value; final rawUpper = value?.toString().toUpperCase() ?? 'IN_APP'; @@ -635,6 +652,99 @@ gentype.SubscriptionInfoIOS? _parseSubscriptionInfoIOS(dynamic value) { return null; } +/// Parse standardized SubscriptionOffer list from iOS native data. +List? _parseSubscriptionOffersIOS(dynamic json) { + if (json == null) return null; + if (json is! List) return null; + + final offers = []; + for (final item in json) { + final map = normalizeDynamicMap(item); + if (map == null) continue; + + // Parse payment mode + gentype.PaymentMode? paymentMode; + final paymentModeRaw = map['paymentMode']?.toString().toUpperCase(); + if (paymentModeRaw != null) { + try { + paymentMode = gentype.PaymentMode.fromJson(paymentModeRaw); + } catch (_) { + // Fallback for non-standard values not handled by fromJson + switch (paymentModeRaw) { + case 'FREETRIAL': + paymentMode = gentype.PaymentMode.FreeTrial; + break; + case 'PAYUPFRONT': + paymentMode = gentype.PaymentMode.PayUpFront; + break; + case 'PAYASYOUGO': + paymentMode = gentype.PaymentMode.PayAsYouGo; + break; + } + } + } + + // Parse offer type + gentype.DiscountOfferType type = gentype.DiscountOfferType.Introductory; + final typeRaw = map['type']?.toString().toUpperCase(); + if (typeRaw != null) { + try { + type = gentype.DiscountOfferType.fromJson(typeRaw); + } catch (_) { + // Fallback for non-standard values not handled by fromJson + switch (typeRaw) { + case 'WIN_BACK': + case 'WINBACK': + case 'CODE': + type = gentype.DiscountOfferType.Promotional; + break; + case 'ONETIME': + type = gentype.DiscountOfferType.OneTime; + break; + } + } + } + + // Parse period + gentype.SubscriptionPeriod? period; + final periodMap = normalizeDynamicMap(map['period']); + if (periodMap != null) { + final unitRaw = periodMap['unit']?.toString().toUpperCase(); + final value = _toInt(periodMap['value']) ?? 1; + gentype.SubscriptionPeriodUnit? unit; + if (unitRaw != null) { + try { + unit = gentype.SubscriptionPeriodUnit.fromJson(unitRaw); + } catch (_) { + // ignore + } + } + if (unit != null) { + period = gentype.SubscriptionPeriod(unit: unit, value: value); + } + } + + offers.add(gentype.SubscriptionOffer( + id: map['id']?.toString() ?? '', + displayPrice: map['displayPrice']?.toString() ?? '', + price: _toDouble(map['price']) ?? 0, + currency: map['currency']?.toString(), + type: type, + paymentMode: paymentMode, + period: period, + periodCount: _toInt(map['periodCount']), + keyIdentifierIOS: map['keyIdentifierIOS']?.toString(), + nonceIOS: map['nonceIOS']?.toString(), + signatureIOS: map['signatureIOS']?.toString(), + timestampIOS: _toDouble(map['timestampIOS']), + numberOfPeriodsIOS: _toInt(map['numberOfPeriodsIOS']), + localizedPriceIOS: map['localizedPriceIOS']?.toString(), + )); + } + + return offers.isEmpty ? null : offers; +} + /// Parse standardized SubscriptionOffer list from subscription offer details. /// Converts legacy subscriptionOfferDetailsAndroid to cross-platform SubscriptionOffer. List _parseSubscriptionOffers( diff --git a/test/helpers_unit_test.dart b/test/helpers_unit_test.dart index fa1a9f8ae..a32dd899d 100644 --- a/test/helpers_unit_test.dart +++ b/test/helpers_unit_test.dart @@ -43,6 +43,74 @@ void main() { expect(subscription.type, types.ProductType.Subs); }); + test( + 'parseProductFromNative parses subscriptionOffers for iOS subscription', + () { + final product = parseProductFromNative( + { + 'platform': 'ios', + 'id': 'premium_yearly', + 'title': 'Premium Yearly', + 'description': 'Yearly plan', + 'currency': 'USD', + 'displayPrice': '\$49.99', + 'price': 49.99, + 'isFamilyShareableIOS': true, + 'jsonRepresentationIOS': '{}', + 'typeIOS': 'AUTO_RENEWABLE_SUBSCRIPTION', + 'subscriptionOffers': >[ + { + 'id': 'intro_offer', + 'displayPrice': 'Free', + 'price': 0.0, + 'type': 'INTRODUCTORY', + 'paymentMode': 'FREE_TRIAL', + 'periodCount': 1, + 'period': { + 'unit': 'WEEK', + 'value': 1, + }, + }, + { + 'id': 'promo_offer', + 'displayPrice': '\$29.99', + 'price': 29.99, + 'type': 'PROMOTIONAL', + 'paymentMode': 'PAY_AS_YOU_GO', + 'periodCount': 3, + 'numberOfPeriodsIOS': 3, + 'localizedPriceIOS': '\$29.99/month', + }, + ], + }, + 'subs', + fallbackIsIOS: true, + ); + + expect(product, isA()); + final subscription = product as types.ProductSubscriptionIOS; + expect(subscription.subscriptionOffers, isNotNull); + expect(subscription.subscriptionOffers, hasLength(2)); + + final introOffer = subscription.subscriptionOffers!.first; + expect(introOffer.id, 'intro_offer'); + expect(introOffer.type, types.DiscountOfferType.Introductory); + expect(introOffer.paymentMode, types.PaymentMode.FreeTrial); + expect(introOffer.price, 0.0); + expect(introOffer.period, isNotNull); + expect(introOffer.period!.unit, types.SubscriptionPeriodUnit.Week); + expect(introOffer.period!.value, 1); + + final promoOffer = subscription.subscriptionOffers![1]; + expect(promoOffer.id, 'promo_offer'); + expect(promoOffer.type, types.DiscountOfferType.Promotional); + expect(promoOffer.paymentMode, types.PaymentMode.PayAsYouGo); + expect(promoOffer.price, 29.99); + expect(promoOffer.numberOfPeriodsIOS, 3); + expect(promoOffer.localizedPriceIOS, '\$29.99/month'); + }, + ); + test( 'parseProductFromNative creates Android in-app product with string offers', () {