diff --git a/RevenueCatUI/Data/TestData.swift b/RevenueCatUI/Data/TestData.swift index c4ec4e6fce..9eb64d0df0 100644 --- a/RevenueCatUI/Data/TestData.swift +++ b/RevenueCatUI/Data/TestData.swift @@ -164,11 +164,18 @@ internal enum TestData { extension TrialOrIntroEligibilityChecker { /// Creates a mock `TrialOrIntroEligibilityChecker` with a constant result. - static func producing(eligibility: IntroEligibilityStatus) -> Self { - return .init { product in - return product.hasIntroDiscount - ? eligibility - : .noIntroOfferExists + static func producing(eligibility: @autoclosure @escaping () -> IntroEligibilityStatus) -> Self { + return .init { packages in + return Dictionary( + uniqueKeysWithValues: Set(packages) + .map { package in + let result = package.storeProduct.hasIntroDiscount + ? eligibility() + : .noIntroOfferExists + + return (package, result) + } + ) } } diff --git a/RevenueCatUI/Helpers/TrialOrIntroEligibilityChecker.swift b/RevenueCatUI/Helpers/TrialOrIntroEligibilityChecker.swift index b13ea06a27..2e9f11f9cc 100644 --- a/RevenueCatUI/Helpers/TrialOrIntroEligibilityChecker.swift +++ b/RevenueCatUI/Helpers/TrialOrIntroEligibilityChecker.swift @@ -11,17 +11,14 @@ import RevenueCat @available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6.2, *) final class TrialOrIntroEligibilityChecker: ObservableObject { - typealias Checker = @Sendable (StoreProduct) async -> IntroEligibilityStatus + typealias Checker = @Sendable ([Package]) async -> [Package: IntroEligibilityStatus] let checker: Checker convenience init(purchases: Purchases = .shared) { - self.init { product in - guard product.hasIntroDiscount else { - return .noIntroOfferExists - } - - return await purchases.checkTrialOrIntroDiscountEligibility(product: product) + self.init { + return await purchases.checkTrialOrIntroDiscountEligibility(packages: $0) + .mapValues(\.status) } } @@ -35,12 +32,13 @@ final class TrialOrIntroEligibilityChecker: ObservableObject { @available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6.2, *) extension TrialOrIntroEligibilityChecker { - func eligibility(for product: StoreProduct) async -> IntroEligibilityStatus { - return await self.checker(product) + func eligibility(for package: Package) async -> IntroEligibilityStatus { + return await self.eligibility(for: [package])[package] ?? .unknown } - func eligibility(for package: Package) async -> IntroEligibilityStatus { - return await self.eligibility(for: package.storeProduct) + /// Computes eligibility for a list of packages in parallel, returning them all in a dictionary. + func eligibility(for packages: [Package]) async -> [Package: IntroEligibilityStatus] { + return await self.checker(packages) } } diff --git a/Sources/Purchasing/IntroEligibility.swift b/Sources/Purchasing/IntroEligibility.swift index e4b7473954..d44421f953 100644 --- a/Sources/Purchasing/IntroEligibility.swift +++ b/Sources/Purchasing/IntroEligibility.swift @@ -128,6 +128,19 @@ private extension IntroEligibilityStatus { self.status = .unknown } + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? Self else { return false } + + return other.status == self.status + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(self.status) + + return hasher.finalize() + } + } extension IntroEligibility { @@ -147,6 +160,23 @@ extension IntroEligibility { } } + public override var debugDescription: String { + let name = "\(type(of: self))" + + switch self.status { + case .eligible: + return "\(name).eligible" + case .ineligible: + return "\(name).ineligible" + case .noIntroOfferExists: + return "\(name).noIntroOfferExists" + case .unknown: + return "\(name).unknown" + @unknown default: + return "Unknown" + } + } + } extension IntroEligibility: Sendable {} diff --git a/Sources/Purchasing/Purchases/Purchases.swift b/Sources/Purchasing/Purchases/Purchases.swift index 0bc3c73d8e..243098d339 100644 --- a/Sources/Purchasing/Purchases/Purchases.swift +++ b/Sources/Purchasing/Purchases/Purchases.swift @@ -891,6 +891,18 @@ public extension Purchases { return await checkTrialOrIntroductoryDiscountEligibilityAsync(productIdentifiers) } + @available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6.2, *) + func checkTrialOrIntroDiscountEligibility(packages: [Package]) async -> [Package: IntroEligibility] { + let result = await self.checkTrialOrIntroDiscountEligibility( + productIdentifiers: packages.map(\.storeProduct.productIdentifier) + ) + + return Set(packages) + .dictionaryWithValues { (package: Package) in + result[package.storeProduct.productIdentifier] ?? .init(eligibilityStatus: .unknown) + } + } + @objc(checkTrialOrIntroDiscountEligibilityForProduct:completion:) func checkTrialOrIntroDiscountEligibility(product: StoreProduct, completion: @escaping (IntroEligibilityStatus) -> Void) { diff --git a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PurchasesAPI.swift b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PurchasesAPI.swift index c184323ef4..90acd6adbd 100644 --- a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PurchasesAPI.swift +++ b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PurchasesAPI.swift @@ -218,6 +218,9 @@ private func checkAsyncMethods(purchases: Purchases) async { let _: [String: IntroEligibility] = await purchases.checkTrialOrIntroDiscountEligibility( productIdentifiers: [String]() ) + let _: [Package: IntroEligibility] = await purchases.checkTrialOrIntroDiscountEligibility( + packages: [Package]() + ) let _: PromotionalOffer = try await purchases.promotionalOffer( forProductDiscount: discount, product: stp diff --git a/Tests/UnitTests/Purchasing/Purchases/PurchasesGetProductsTests.swift b/Tests/UnitTests/Purchasing/Purchases/PurchasesGetProductsTests.swift index cf03354cc0..a852a33d39 100644 --- a/Tests/UnitTests/Purchasing/Purchases/PurchasesGetProductsTests.swift +++ b/Tests/UnitTests/Purchasing/Purchases/PurchasesGetProductsTests.swift @@ -55,6 +55,51 @@ class PurchasesGetProductsTests: BasePurchasesTests { ) == true } + @available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6.2, *) + func testGetEligibilityForPackages() async throws { + try AvailabilityChecks.iOS13APIAvailableOrSkipTest() + + let packages: [Package] = [ + .init(identifier: "package1", + packageType: .weekly, + storeProduct: .init(sk1Product: MockSK1Product(mockProductIdentifier: "product1")), + offeringIdentifier: "offering"), + .init(identifier: "package2", + packageType: .monthly, + storeProduct: .init(sk1Product: MockSK1Product(mockProductIdentifier: "product2")), + offeringIdentifier: "offering"), + .init(identifier: "package3", + packageType: .annual, + storeProduct: .init(sk1Product: MockSK1Product(mockProductIdentifier: "product3")), + offeringIdentifier: "offering"), + .init(identifier: "package4", + packageType: .annual, + storeProduct: .init(sk1Product: MockSK1Product(mockProductIdentifier: "product4")), + offeringIdentifier: "offering"), + .init(identifier: "package5", + packageType: .annual, + storeProduct: .init(sk1Product: MockSK1Product(mockProductIdentifier: "product1")), + offeringIdentifier: "offering") + ] + + self.trialOrIntroPriceEligibilityChecker + .stubbedCheckTrialOrIntroPriceEligibilityFromOptimalStoreReceiveEligibilityResult = [ + "product1": .init(eligibilityStatus: .eligible), + "product2": .init(eligibilityStatus: .noIntroOfferExists), + "product3": .init(eligibilityStatus: .ineligible) + ] + + let result = await self.purchases.checkTrialOrIntroDiscountEligibility(packages: packages) + + expect(result) == [ + packages[0]: .init(eligibilityStatus: .eligible), + packages[1]: .init(eligibilityStatus: .noIntroOfferExists), + packages[2]: .init(eligibilityStatus: .ineligible), + packages[3]: .init(eligibilityStatus: .unknown), + packages[4]: .init(eligibilityStatus: .eligible) + ] + } + } class PurchasesGetProductsBackgroundTests: BasePurchasesTests {