Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs/blog/2026-02-12-8.2.7-subscription-offers-fix.md
Original file line number Diff line number Diff line change
@@ -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<ProductSubscription>(
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)
110 changes: 110 additions & 0 deletions lib/helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ gentype.ProductCommon parseProductFromNative(
subscriptionInfoIOS: _parseSubscriptionInfoIOS(
json['subscriptionInfoIOS'] ?? json['subscription'],
),
subscriptionOffers: _parseSubscriptionOffersIOS(
json['subscriptionOffers'],
),
subscriptionPeriodNumberIOS:
json['subscriptionPeriodNumberIOS']?.toString(),
subscriptionPeriodUnitIOS: _parseSubscriptionPeriod(
Expand Down Expand Up @@ -463,6 +466,20 @@ List<gentype.Purchase> 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';
Expand Down Expand Up @@ -635,6 +652,99 @@ gentype.SubscriptionInfoIOS? _parseSubscriptionInfoIOS(dynamic value) {
return null;
}

/// Parse standardized SubscriptionOffer list from iOS native data.
List<gentype.SubscriptionOffer>? _parseSubscriptionOffersIOS(dynamic json) {
if (json == null) return null;
if (json is! List) return null;

final offers = <gentype.SubscriptionOffer>[];
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<gentype.SubscriptionOffer> _parseSubscriptionOffers(
Expand Down
68 changes: 68 additions & 0 deletions test/helpers_unit_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,74 @@ void main() {
expect(subscription.type, types.ProductType.Subs);
});

test(
'parseProductFromNative parses subscriptionOffers for iOS subscription',
() {
final product = parseProductFromNative(
<String, dynamic>{
'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': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'intro_offer',
'displayPrice': 'Free',
'price': 0.0,
'type': 'INTRODUCTORY',
'paymentMode': 'FREE_TRIAL',
'periodCount': 1,
'period': <String, dynamic>{
'unit': 'WEEK',
'value': 1,
},
},
<String, dynamic>{
'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<types.ProductSubscriptionIOS>());
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',
() {
Expand Down