Skip to content

Commit 2c52f24

Browse files
authored
[in_app_purchase_storekit] isIntroductoryOfferEligible implementation (#9499)
Added `isEligibleForIntroOffer` method support https://developer.apple.com/documentation/storekit/product/subscriptioninfo/iseligibleforintrooffer ## Pre-Review Checklist [^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling.
1 parent e4fd6c0 commit 2c52f24

File tree

11 files changed

+323
-2
lines changed

11 files changed

+323
-2
lines changed

packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.4.3
2+
3+
* Adds **Introductory Offer Eligibility** support for StoreKit2
4+
15
## 0.4.2
26

37
* Add [jwsRepresentation](https://developer.apple.com/documentation/storekit/verificationresult/jwsrepresentation-21vgo) to `SK2PurchaseDetails` as `serverVerificationData` for secure server-side purchase verification. Use this JSON Web Signature (JWS) value to perform your own JWS verification on your server.

packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,53 @@ extension InAppPurchasePlugin: InAppPurchase2API {
182182
}
183183
}
184184

185+
/// Checks if the user is eligible for an introductory offer.
186+
///
187+
/// - Parameters:
188+
/// - productId: The product ID associated with the offer.
189+
/// - completion: Returns `Bool` for eligibility or `Error` on failure.
190+
///
191+
/// - Availability: iOS 15.0+, macOS 12.0+
192+
func isIntroductoryOfferEligible(
193+
productId: String,
194+
completion: @escaping (Result<Bool, Error>) -> Void
195+
) {
196+
Task {
197+
do {
198+
guard let product = try await Product.products(for: [productId]).first else {
199+
completion(
200+
.failure(
201+
PigeonError(
202+
code: "storekit2_failed_to_fetch_product",
203+
message: "Storekit has failed to fetch this product.",
204+
details: "Product ID: \(productId)")))
205+
return
206+
}
207+
208+
guard let subscription = product.subscription else {
209+
completion(
210+
.failure(
211+
PigeonError(
212+
code: "storekit2_not_subscription",
213+
message: "Product is not a subscription",
214+
details: "Product ID: \(productId)")))
215+
return
216+
}
217+
218+
let isEligible = await subscription.isEligibleForIntroOffer
219+
220+
completion(.success(isEligible))
221+
} catch {
222+
completion(
223+
.failure(
224+
PigeonError(
225+
code: "storekit2_eligibility_check_failed",
226+
message: "Failed to check offer eligibility: \(error.localizedDescription)",
227+
details: "Product ID: \(productId), Error: \(error)")))
228+
}
229+
}
230+
}
231+
185232
/// Wrapper method around StoreKit2's transactions() method
186233
/// https://developer.apple.com/documentation/storekit/product/3851116-products
187234
func transactions(

packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/sk2_pigeon.g.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,8 @@ protocol InAppPurchase2API {
691691
completion: @escaping (Result<SK2ProductPurchaseResultMessage, Error>) -> Void)
692692
func isWinBackOfferEligible(
693693
productId: String, offerId: String, completion: @escaping (Result<Bool, Error>) -> Void)
694+
func isIntroductoryOfferEligible(
695+
productId: String, completion: @escaping (Result<Bool, Error>) -> Void)
694696
func transactions(completion: @escaping (Result<[SK2TransactionMessage], Error>) -> Void)
695697
func finish(id: Int64, completion: @escaping (Result<Void, Error>) -> Void)
696698
func startListeningToTransactions() throws
@@ -787,6 +789,26 @@ class InAppPurchase2APISetup {
787789
} else {
788790
isWinBackOfferEligibleChannel.setMessageHandler(nil)
789791
}
792+
let isIntroductoryOfferEligibleChannel = FlutterBasicMessageChannel(
793+
name:
794+
"dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isIntroductoryOfferEligible\(channelSuffix)",
795+
binaryMessenger: binaryMessenger, codec: codec)
796+
if let api = api {
797+
isIntroductoryOfferEligibleChannel.setMessageHandler { message, reply in
798+
let args = message as! [Any?]
799+
let productIdArg = args[0] as! String
800+
api.isIntroductoryOfferEligible(productId: productIdArg) { result in
801+
switch result {
802+
case .success(let res):
803+
reply(wrapResult(res))
804+
case .failure(let error):
805+
reply(wrapError(error))
806+
}
807+
}
808+
}
809+
} else {
810+
isIntroductoryOfferEligibleChannel.setMessageHandler(nil)
811+
}
790812
let transactionsChannel = FlutterBasicMessageChannel(
791813
name:
792814
"dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.transactions\(channelSuffix)",

packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,33 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform {
355355
return _useStoreKit2;
356356
}
357357

358+
/// Checks if the user is eligible for an introductory offer (StoreKit2 only).
359+
///
360+
/// Throws [PlatformException] if StoreKit2 is not enabled, if the product is not found,
361+
/// if the product is not a subscription, or if any error occurs during the eligibility check.
362+
///
363+
/// [PlatformException.code] can be one of:
364+
/// - `storekit2_not_enabled`
365+
/// - `storekit2_failed_to_fetch_product`
366+
/// - `storekit2_not_subscription`
367+
/// - `storekit2_eligibility_check_failed`
368+
Future<bool> isIntroductoryOfferEligible(
369+
String productId,
370+
) async {
371+
if (!_useStoreKit2) {
372+
throw PlatformException(
373+
code: 'storekit2_not_enabled',
374+
message: 'Win back offers require StoreKit2 which is not enabled.',
375+
);
376+
}
377+
378+
final bool eligibility = await SK2Product.isIntroductoryOfferEligible(
379+
productId,
380+
);
381+
382+
return eligibility;
383+
}
384+
358385
/// Checks if the user is eligible for a specific win back offer (StoreKit2 only).
359386
///
360387
/// Throws [PlatformException] if StoreKit2 is not enabled, if the product is not found,

packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,6 +942,37 @@ class InAppPurchase2API {
942942
}
943943
}
944944

945+
Future<bool> isIntroductoryOfferEligible(String productId) async {
946+
final String pigeonVar_channelName =
947+
'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isIntroductoryOfferEligible$pigeonVar_messageChannelSuffix';
948+
final BasicMessageChannel<Object?> pigeonVar_channel =
949+
BasicMessageChannel<Object?>(
950+
pigeonVar_channelName,
951+
pigeonChannelCodec,
952+
binaryMessenger: pigeonVar_binaryMessenger,
953+
);
954+
final Future<Object?> pigeonVar_sendFuture =
955+
pigeonVar_channel.send(<Object?>[productId]);
956+
final List<Object?>? pigeonVar_replyList =
957+
await pigeonVar_sendFuture as List<Object?>?;
958+
if (pigeonVar_replyList == null) {
959+
throw _createConnectionError(pigeonVar_channelName);
960+
} else if (pigeonVar_replyList.length > 1) {
961+
throw PlatformException(
962+
code: pigeonVar_replyList[0]! as String,
963+
message: pigeonVar_replyList[1] as String?,
964+
details: pigeonVar_replyList[2],
965+
);
966+
} else if (pigeonVar_replyList[0] == null) {
967+
throw PlatformException(
968+
code: 'null-error',
969+
message: 'Host platform returned null value for non-null return value.',
970+
);
971+
} else {
972+
return (pigeonVar_replyList[0] as bool?)!;
973+
}
974+
}
975+
945976
Future<List<SK2TransactionMessage>> transactions() async {
946977
final String pigeonVar_channelName =
947978
'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.transactions$pigeonVar_messageChannelSuffix';

packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_product_wrapper.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,18 @@ class SK2Product {
400400
return result.convertFromPigeon();
401401
}
402402

403+
/// Checks if the user is eligible for an introductory offer.
404+
/// The product must be an auto-renewable subscription.
405+
static Future<bool> isIntroductoryOfferEligible(
406+
String productId,
407+
) async {
408+
final bool result = await _hostApi.isIntroductoryOfferEligible(
409+
productId,
410+
);
411+
412+
return result;
413+
}
414+
403415
/// Checks if the user is eligible for a specific win back offer.
404416
static Future<bool> isWinBackOfferEligible(
405417
String productId,

packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,9 @@ abstract class InAppPurchase2API {
225225
@async
226226
bool isWinBackOfferEligible(String productId, String offerId);
227227

228+
@async
229+
bool isIntroductoryOfferEligible(String productId);
230+
228231
@async
229232
List<SK2TransactionMessage> transactions();
230233

packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: in_app_purchase_storekit
22
description: An implementation for the iOS and macOS platforms of the Flutter `in_app_purchase` plugin. This uses the StoreKit Framework.
33
repository: https://github.com/flutter/packages/tree/main/packages/in_app_purchase/in_app_purchase_storekit
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22
5-
version: 0.4.2
5+
version: 0.4.3
66

77
environment:
88
sdk: ^3.6.0

packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ class FakeStoreKit2Platform implements TestInAppPurchase2Api {
301301
bool isListenerRegistered = false;
302302
SK2ProductPurchaseOptionsMessage? lastPurchaseOptions;
303303
Map<String, Set<String>> eligibleWinBackOffers = <String, Set<String>>{};
304+
Map<String, bool> eligibleIntroductoryOffers = <String, bool>{};
304305

305306
void reset() {
306307
validProductIDs = <String>{'123', '456'};
@@ -318,6 +319,7 @@ class FakeStoreKit2Platform implements TestInAppPurchase2Api {
318319
validProducts[validID] = product;
319320
}
320321
eligibleWinBackOffers = <String, Set<String>>{};
322+
eligibleIntroductoryOffers = <String, bool>{};
321323
}
322324

323325
SK2TransactionMessage createRestoredTransaction(
@@ -434,6 +436,29 @@ class FakeStoreKit2Platform implements TestInAppPurchase2Api {
434436

435437
return eligibleWinBackOffers[productId]?.contains(offerId) ?? false;
436438
}
439+
440+
@override
441+
Future<bool> isIntroductoryOfferEligible(
442+
String productId,
443+
) async {
444+
if (!validProductIDs.contains(productId)) {
445+
throw PlatformException(
446+
code: 'storekit2_failed_to_fetch_product',
447+
message: 'StoreKit failed to fetch product',
448+
details: 'Product ID: $productId',
449+
);
450+
}
451+
452+
if (validProducts[productId]?.type != SK2ProductType.autoRenewable) {
453+
throw PlatformException(
454+
code: 'storekit2_not_subscription',
455+
message: 'Product is not a subscription',
456+
details: 'Product ID: $productId',
457+
);
458+
}
459+
460+
return eligibleIntroductoryOffers[productId] ?? false;
461+
}
437462
}
438463

439464
SK2TransactionMessage createPendingTransaction(String id, {int quantity = 1}) {

packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,4 +434,119 @@ void main() {
434434
);
435435
});
436436
});
437+
438+
group('introductory offers eligibility', () {
439+
late FakeStoreKit2Platform fakeStoreKit2Platform;
440+
441+
setUp(() async {
442+
fakeStoreKit2Platform = FakeStoreKit2Platform();
443+
fakeStoreKit2Platform.reset();
444+
TestInAppPurchase2Api.setUp(fakeStoreKit2Platform);
445+
await InAppPurchaseStoreKitPlatform.enableStoreKit2();
446+
});
447+
448+
test('should return true when introductory offer is eligible', () async {
449+
fakeStoreKit2Platform.validProductIDs = <String>{'sub1'};
450+
fakeStoreKit2Platform.eligibleIntroductoryOffers['sub1'] = true;
451+
fakeStoreKit2Platform.validProducts['sub1'] = SK2Product(
452+
id: 'sub1',
453+
displayName: 'Subscription',
454+
displayPrice: r'$9.99',
455+
description: 'Monthly subscription',
456+
price: 9.99,
457+
type: SK2ProductType.autoRenewable,
458+
subscription: const SK2SubscriptionInfo(
459+
subscriptionGroupID: 'group1',
460+
promotionalOffers: <SK2SubscriptionOffer>[],
461+
subscriptionPeriod: SK2SubscriptionPeriod(
462+
value: 1,
463+
unit: SK2SubscriptionPeriodUnit.month,
464+
),
465+
),
466+
priceLocale: SK2PriceLocale(currencyCode: 'USD', currencySymbol: r'$'),
467+
);
468+
469+
final bool result =
470+
await iapStoreKitPlatform.isIntroductoryOfferEligible('sub1');
471+
472+
expect(result, isTrue);
473+
});
474+
475+
test('should return false when introductory offer is not eligible',
476+
() async {
477+
fakeStoreKit2Platform.validProductIDs = <String>{'sub1'};
478+
fakeStoreKit2Platform.eligibleIntroductoryOffers['sub1'] = false;
479+
fakeStoreKit2Platform.validProducts['sub1'] = SK2Product(
480+
id: 'sub1',
481+
displayName: 'Subscription',
482+
displayPrice: r'$9.99',
483+
description: 'Monthly subscription',
484+
price: 9.99,
485+
type: SK2ProductType.autoRenewable,
486+
subscription: const SK2SubscriptionInfo(
487+
subscriptionGroupID: 'group1',
488+
promotionalOffers: <SK2SubscriptionOffer>[],
489+
subscriptionPeriod: SK2SubscriptionPeriod(
490+
value: 1,
491+
unit: SK2SubscriptionPeriodUnit.month,
492+
),
493+
),
494+
priceLocale: SK2PriceLocale(currencyCode: 'USD', currencySymbol: r'$'),
495+
);
496+
497+
final bool result =
498+
await iapStoreKitPlatform.isIntroductoryOfferEligible('sub1');
499+
500+
expect(result, isFalse);
501+
});
502+
503+
test('should throw product not found error for invalid product', () async {
504+
expect(
505+
() =>
506+
iapStoreKitPlatform.isIntroductoryOfferEligible('invalid_product'),
507+
throwsA(isA<PlatformException>().having(
508+
(PlatformException e) => e.code,
509+
'code',
510+
'storekit2_failed_to_fetch_product',
511+
)),
512+
);
513+
});
514+
515+
test('should throw subscription error for non-subscription product',
516+
() async {
517+
fakeStoreKit2Platform.validProductIDs = <String>{'consumable1'};
518+
fakeStoreKit2Platform.validProducts['consumable1'] = SK2Product(
519+
id: 'consumable1',
520+
displayName: 'Coins',
521+
displayPrice: r'$0.99',
522+
description: 'Game currency',
523+
price: 0.99,
524+
type: SK2ProductType.consumable,
525+
priceLocale: SK2PriceLocale(currencyCode: 'USD', currencySymbol: r'$'),
526+
);
527+
528+
expect(
529+
() => iapStoreKitPlatform.isIntroductoryOfferEligible('consumable1'),
530+
throwsA(isA<PlatformException>().having(
531+
(PlatformException e) => e.code,
532+
'code',
533+
'storekit2_not_subscription',
534+
)),
535+
);
536+
});
537+
538+
test('should throw platform exception when StoreKit2 is not supported',
539+
() async {
540+
await InAppPurchaseStoreKitPlatform.enableStoreKit1();
541+
542+
expect(
543+
() => iapStoreKitPlatform.isIntroductoryOfferEligible('sub1'),
544+
throwsA(isA<PlatformException>().having(
545+
(PlatformException e) => e.code,
546+
'code',
547+
'storekit2_not_enabled',
548+
)),
549+
);
550+
});
551+
});
437552
}

0 commit comments

Comments
 (0)