Skip to content

Commit 96efa55

Browse files
hyochanclaude
andcommitted
fix: parse subscriptionOffers for iOS subscription products
- Add _parseSubscriptionOffersIOS helper function - Parse subscriptionOffers from native iOS data in ProductSubscriptionIOS - Add unit test for subscriptionOffers parsing Fixes #616 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ac6aff4 commit 96efa55

File tree

2 files changed

+168
-0
lines changed

2 files changed

+168
-0
lines changed

lib/helpers.dart

Lines changed: 100 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(
@@ -635,6 +638,103 @@ gentype.SubscriptionInfoIOS? _parseSubscriptionInfoIOS(dynamic value) {
635638
return null;
636639
}
637640

641+
/// Parse standardized SubscriptionOffer list from iOS native data.
642+
List<gentype.SubscriptionOffer>? _parseSubscriptionOffersIOS(dynamic json) {
643+
if (json == null) return null;
644+
if (json is! List) return null;
645+
646+
final offers = <gentype.SubscriptionOffer>[];
647+
for (final item in json) {
648+
final map = normalizeDynamicMap(item);
649+
if (map == null) continue;
650+
651+
// Parse payment mode
652+
gentype.PaymentMode? paymentMode;
653+
final paymentModeRaw = map['paymentMode']?.toString().toUpperCase();
654+
if (paymentModeRaw != null) {
655+
try {
656+
paymentMode = gentype.PaymentMode.fromJson(paymentModeRaw);
657+
} catch (_) {
658+
// Fallback mapping
659+
switch (paymentModeRaw) {
660+
case 'FREE_TRIAL':
661+
case 'FREETRIAL':
662+
paymentMode = gentype.PaymentMode.FreeTrial;
663+
break;
664+
case 'PAY_UP_FRONT':
665+
case 'PAYUPFRONT':
666+
paymentMode = gentype.PaymentMode.PayUpFront;
667+
break;
668+
case 'PAY_AS_YOU_GO':
669+
case 'PAYASYOUGO':
670+
paymentMode = gentype.PaymentMode.PayAsYouGo;
671+
break;
672+
}
673+
}
674+
}
675+
676+
// Parse offer type
677+
gentype.DiscountOfferType type = gentype.DiscountOfferType.Introductory;
678+
final typeRaw = map['type']?.toString().toUpperCase();
679+
if (typeRaw != null) {
680+
try {
681+
type = gentype.DiscountOfferType.fromJson(typeRaw);
682+
} catch (_) {
683+
switch (typeRaw) {
684+
case 'PROMOTIONAL':
685+
case 'WIN_BACK':
686+
case 'WINBACK':
687+
case 'CODE':
688+
type = gentype.DiscountOfferType.Promotional;
689+
break;
690+
case 'ONE_TIME':
691+
case 'ONETIME':
692+
type = gentype.DiscountOfferType.OneTime;
693+
break;
694+
}
695+
}
696+
}
697+
698+
// Parse period
699+
gentype.SubscriptionPeriod? period;
700+
final periodMap = normalizeDynamicMap(map['period']);
701+
if (periodMap != null) {
702+
final unitRaw = periodMap['unit']?.toString().toUpperCase();
703+
final value = (periodMap['value'] as num?)?.toInt() ?? 1;
704+
gentype.SubscriptionPeriodUnit? unit;
705+
if (unitRaw != null) {
706+
try {
707+
unit = gentype.SubscriptionPeriodUnit.fromJson(unitRaw);
708+
} catch (_) {
709+
// ignore
710+
}
711+
}
712+
if (unit != null) {
713+
period = gentype.SubscriptionPeriod(unit: unit, value: value);
714+
}
715+
}
716+
717+
offers.add(gentype.SubscriptionOffer(
718+
id: map['id']?.toString() ?? '',
719+
displayPrice: map['displayPrice']?.toString() ?? '',
720+
price: (map['price'] as num?)?.toDouble() ?? 0,
721+
currency: map['currency']?.toString(),
722+
type: type,
723+
paymentMode: paymentMode,
724+
period: period,
725+
periodCount: (map['periodCount'] as num?)?.toInt(),
726+
keyIdentifierIOS: map['keyIdentifierIOS']?.toString(),
727+
nonceIOS: map['nonceIOS']?.toString(),
728+
signatureIOS: map['signatureIOS']?.toString(),
729+
timestampIOS: (map['timestampIOS'] as num?)?.toDouble(),
730+
numberOfPeriodsIOS: (map['numberOfPeriodsIOS'] as num?)?.toInt(),
731+
localizedPriceIOS: map['localizedPriceIOS']?.toString(),
732+
));
733+
}
734+
735+
return offers.isEmpty ? null : offers;
736+
}
737+
638738
/// Parse standardized SubscriptionOffer list from subscription offer details.
639739
/// Converts legacy subscriptionOfferDetailsAndroid to cross-platform SubscriptionOffer.
640740
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)