Skip to content

Commit e4905a2

Browse files
hyochanclaude
andauthored
fix: parse subscriptionOffers for iOS subscription products (#617)
## Summary - Fix `ProductSubscriptionIOS.subscriptionOffers` always being null - Add parsing logic for iOS `subscriptionOffers` from native data ## Changes - Add `_parseSubscriptionOffersIOS` helper function in `helpers.dart` - Parse `subscriptionOffers` field when creating `ProductSubscriptionIOS` - Add unit test for subscriptionOffers parsing ## Related - Fixes #616 ## Test plan - [x] `dart format --set-exit-if-changed .` passes - [x] `flutter analyze` passes - [x] `flutter test` passes (208 tests) Generated with [Claude Code](https://claude.ai/code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * iOS subscription products now surface subscription offers (Introductory and Promotional) including payment mode, price, period, period count, and localized price. * **Bug Fixes** * Fixed iOS parsing so subscription offers are correctly detected and returned when present. * **Tests** * Added unit tests validating parsing and exposure of iOS subscription offers and their fields. * **Documentation** * Added release notes describing the iOS subscription offers fix and example usage. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ac6aff4 commit e4905a2

File tree

3 files changed

+227
-0
lines changed

3 files changed

+227
-0
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
slug: 8.2.7
3+
title: "8.2.7 - iOS subscriptionOffers Fix"
4+
authors: [hyochan]
5+
tags: [release, bug-fix]
6+
---
7+
8+
# 8.2.7
9+
10+
This release fixes a bug where `subscriptionOffers` was always null on iOS subscription products.
11+
12+
## Bug Fixes
13+
14+
### subscriptionOffers Parsing for iOS
15+
16+
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.
17+
18+
```dart
19+
final subscriptions = await iap.fetchProducts<ProductSubscription>(
20+
skus: ['premium_monthly'],
21+
type: ProductQueryType.Subs,
22+
);
23+
24+
for (final sub in subscriptions) {
25+
if (sub is ProductSubscriptionIOS) {
26+
// Now correctly populated
27+
final offers = sub.subscriptionOffers;
28+
if (offers != null) {
29+
for (final offer in offers) {
30+
print('Offer: ${offer.id}');
31+
print('Type: ${offer.type}'); // introductory, promotional
32+
print('Payment Mode: ${offer.paymentMode}'); // freeTrial, payAsYouGo, etc.
33+
print('Price: ${offer.displayPrice}');
34+
}
35+
}
36+
}
37+
}
38+
```
39+
40+
## Installation
41+
42+
```yaml
43+
dependencies:
44+
flutter_inapp_purchase: ^8.2.7
45+
```
46+
47+
## Related
48+
49+
- [Issue #616](https://github.com/hyochan/flutter_inapp_purchase/issues/616)

lib/helpers.dart

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ gentype.ProductCommon parseProductFromNative(
121121
subscriptionInfoIOS: _parseSubscriptionInfoIOS(
122122
json['subscriptionInfoIOS'] ?? json['subscription'],
123123
),
124+
subscriptionOffers: _parseSubscriptionOffersIOS(
125+
json['subscriptionOffers'],
126+
),
124127
subscriptionPeriodNumberIOS:
125128
json['subscriptionPeriodNumberIOS']?.toString(),
126129
subscriptionPeriodUnitIOS: _parseSubscriptionPeriod(
@@ -463,6 +466,20 @@ List<gentype.Purchase> extractPurchases(
463466

464467
// Private helper functions --------------------------------------------------
465468

469+
/// Safe double parsing that handles both num and String inputs.
470+
double? _toDouble(dynamic value) {
471+
if (value is num) return value.toDouble();
472+
if (value is String) return double.tryParse(value);
473+
return null;
474+
}
475+
476+
/// Safe int parsing that handles both num and String inputs.
477+
int? _toInt(dynamic value) {
478+
if (value is num) return value.toInt();
479+
if (value is String) return int.tryParse(value);
480+
return null;
481+
}
482+
466483
gentype.ProductType _parseProductType(dynamic value) {
467484
if (value is gentype.ProductType) return value;
468485
final rawUpper = value?.toString().toUpperCase() ?? 'IN_APP';
@@ -635,6 +652,99 @@ gentype.SubscriptionInfoIOS? _parseSubscriptionInfoIOS(dynamic value) {
635652
return null;
636653
}
637654

655+
/// Parse standardized SubscriptionOffer list from iOS native data.
656+
List<gentype.SubscriptionOffer>? _parseSubscriptionOffersIOS(dynamic json) {
657+
if (json == null) return null;
658+
if (json is! List) return null;
659+
660+
final offers = <gentype.SubscriptionOffer>[];
661+
for (final item in json) {
662+
final map = normalizeDynamicMap(item);
663+
if (map == null) continue;
664+
665+
// Parse payment mode
666+
gentype.PaymentMode? paymentMode;
667+
final paymentModeRaw = map['paymentMode']?.toString().toUpperCase();
668+
if (paymentModeRaw != null) {
669+
try {
670+
paymentMode = gentype.PaymentMode.fromJson(paymentModeRaw);
671+
} catch (_) {
672+
// Fallback for non-standard values not handled by fromJson
673+
switch (paymentModeRaw) {
674+
case 'FREETRIAL':
675+
paymentMode = gentype.PaymentMode.FreeTrial;
676+
break;
677+
case 'PAYUPFRONT':
678+
paymentMode = gentype.PaymentMode.PayUpFront;
679+
break;
680+
case 'PAYASYOUGO':
681+
paymentMode = gentype.PaymentMode.PayAsYouGo;
682+
break;
683+
}
684+
}
685+
}
686+
687+
// Parse offer type
688+
gentype.DiscountOfferType type = gentype.DiscountOfferType.Introductory;
689+
final typeRaw = map['type']?.toString().toUpperCase();
690+
if (typeRaw != null) {
691+
try {
692+
type = gentype.DiscountOfferType.fromJson(typeRaw);
693+
} catch (_) {
694+
// Fallback for non-standard values not handled by fromJson
695+
switch (typeRaw) {
696+
case 'WIN_BACK':
697+
case 'WINBACK':
698+
case 'CODE':
699+
type = gentype.DiscountOfferType.Promotional;
700+
break;
701+
case 'ONETIME':
702+
type = gentype.DiscountOfferType.OneTime;
703+
break;
704+
}
705+
}
706+
}
707+
708+
// Parse period
709+
gentype.SubscriptionPeriod? period;
710+
final periodMap = normalizeDynamicMap(map['period']);
711+
if (periodMap != null) {
712+
final unitRaw = periodMap['unit']?.toString().toUpperCase();
713+
final value = _toInt(periodMap['value']) ?? 1;
714+
gentype.SubscriptionPeriodUnit? unit;
715+
if (unitRaw != null) {
716+
try {
717+
unit = gentype.SubscriptionPeriodUnit.fromJson(unitRaw);
718+
} catch (_) {
719+
// ignore
720+
}
721+
}
722+
if (unit != null) {
723+
period = gentype.SubscriptionPeriod(unit: unit, value: value);
724+
}
725+
}
726+
727+
offers.add(gentype.SubscriptionOffer(
728+
id: map['id']?.toString() ?? '',
729+
displayPrice: map['displayPrice']?.toString() ?? '',
730+
price: _toDouble(map['price']) ?? 0,
731+
currency: map['currency']?.toString(),
732+
type: type,
733+
paymentMode: paymentMode,
734+
period: period,
735+
periodCount: _toInt(map['periodCount']),
736+
keyIdentifierIOS: map['keyIdentifierIOS']?.toString(),
737+
nonceIOS: map['nonceIOS']?.toString(),
738+
signatureIOS: map['signatureIOS']?.toString(),
739+
timestampIOS: _toDouble(map['timestampIOS']),
740+
numberOfPeriodsIOS: _toInt(map['numberOfPeriodsIOS']),
741+
localizedPriceIOS: map['localizedPriceIOS']?.toString(),
742+
));
743+
}
744+
745+
return offers.isEmpty ? null : offers;
746+
}
747+
638748
/// Parse standardized SubscriptionOffer list from subscription offer details.
639749
/// Converts legacy subscriptionOfferDetailsAndroid to cross-platform SubscriptionOffer.
640750
List<gentype.SubscriptionOffer> _parseSubscriptionOffers(

test/helpers_unit_test.dart

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,74 @@ void main() {
4343
expect(subscription.type, types.ProductType.Subs);
4444
});
4545

46+
test(
47+
'parseProductFromNative parses subscriptionOffers for iOS subscription',
48+
() {
49+
final product = parseProductFromNative(
50+
<String, dynamic>{
51+
'platform': 'ios',
52+
'id': 'premium_yearly',
53+
'title': 'Premium Yearly',
54+
'description': 'Yearly plan',
55+
'currency': 'USD',
56+
'displayPrice': '\$49.99',
57+
'price': 49.99,
58+
'isFamilyShareableIOS': true,
59+
'jsonRepresentationIOS': '{}',
60+
'typeIOS': 'AUTO_RENEWABLE_SUBSCRIPTION',
61+
'subscriptionOffers': <Map<String, dynamic>>[
62+
<String, dynamic>{
63+
'id': 'intro_offer',
64+
'displayPrice': 'Free',
65+
'price': 0.0,
66+
'type': 'INTRODUCTORY',
67+
'paymentMode': 'FREE_TRIAL',
68+
'periodCount': 1,
69+
'period': <String, dynamic>{
70+
'unit': 'WEEK',
71+
'value': 1,
72+
},
73+
},
74+
<String, dynamic>{
75+
'id': 'promo_offer',
76+
'displayPrice': '\$29.99',
77+
'price': 29.99,
78+
'type': 'PROMOTIONAL',
79+
'paymentMode': 'PAY_AS_YOU_GO',
80+
'periodCount': 3,
81+
'numberOfPeriodsIOS': 3,
82+
'localizedPriceIOS': '\$29.99/month',
83+
},
84+
],
85+
},
86+
'subs',
87+
fallbackIsIOS: true,
88+
);
89+
90+
expect(product, isA<types.ProductSubscriptionIOS>());
91+
final subscription = product as types.ProductSubscriptionIOS;
92+
expect(subscription.subscriptionOffers, isNotNull);
93+
expect(subscription.subscriptionOffers, hasLength(2));
94+
95+
final introOffer = subscription.subscriptionOffers!.first;
96+
expect(introOffer.id, 'intro_offer');
97+
expect(introOffer.type, types.DiscountOfferType.Introductory);
98+
expect(introOffer.paymentMode, types.PaymentMode.FreeTrial);
99+
expect(introOffer.price, 0.0);
100+
expect(introOffer.period, isNotNull);
101+
expect(introOffer.period!.unit, types.SubscriptionPeriodUnit.Week);
102+
expect(introOffer.period!.value, 1);
103+
104+
final promoOffer = subscription.subscriptionOffers![1];
105+
expect(promoOffer.id, 'promo_offer');
106+
expect(promoOffer.type, types.DiscountOfferType.Promotional);
107+
expect(promoOffer.paymentMode, types.PaymentMode.PayAsYouGo);
108+
expect(promoOffer.price, 29.99);
109+
expect(promoOffer.numberOfPeriodsIOS, 3);
110+
expect(promoOffer.localizedPriceIOS, '\$29.99/month');
111+
},
112+
);
113+
46114
test(
47115
'parseProductFromNative creates Android in-app product with string offers',
48116
() {

0 commit comments

Comments
 (0)