diff --git a/Sources/Subscription/Flows/Models/SubscriptionOptions.swift b/Sources/Subscription/Flows/Models/SubscriptionOptions.swift index 5544cf680..2c69e31f6 100644 --- a/Sources/Subscription/Flows/Models/SubscriptionOptions.swift +++ b/Sources/Subscription/Flows/Models/SubscriptionOptions.swift @@ -50,6 +50,13 @@ public enum SubscriptionPlatformName: String, Encodable { public struct SubscriptionOption: Encodable, Equatable { let id: String let cost: SubscriptionOptionCost + let offer: SubscriptionOptionOffer? + + init(id: String, cost: SubscriptionOptionCost, offer: SubscriptionOptionOffer? = nil) { + self.id = id + self.cost = cost + self.offer = offer + } } struct SubscriptionOptionCost: Encodable, Equatable { @@ -60,3 +67,17 @@ struct SubscriptionOptionCost: Encodable, Equatable { public struct SubscriptionFeature: Encodable, Equatable { let name: Entitlement.ProductName } + +/// A `SubscriptionOptionOffer` represents an offer (e.g Free Trials) associated with a Subscription +public struct SubscriptionOptionOffer: Encodable, Equatable { + + public enum OfferType: String, Codable, CaseIterable { + case freeTrial + } + + let type: OfferType + let id: String + let displayPrice: String + let durationInDays: Int + let isUserEligible: Bool +} diff --git a/Sources/Subscription/Managers/StorePurchaseManager/ProductFetching.swift b/Sources/Subscription/Managers/StorePurchaseManager/ProductFetching.swift new file mode 100644 index 000000000..485bd8a6a --- /dev/null +++ b/Sources/Subscription/Managers/StorePurchaseManager/ProductFetching.swift @@ -0,0 +1,45 @@ +// +// ProductFetching.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import StoreKit + +/// A protocol for types that can fetch subscription products. +@available(macOS 12.0, iOS 15.0, *) +public protocol ProductFetching { + /// Fetches products for the specified identifiers. + /// - Parameter identifiers: An array of product identifiers to fetch. + /// - Returns: An array of subscription products. + /// - Throws: An error if the fetch operation fails. + func products(for identifiers: [String]) async throws -> [any SubscriptionProduct] +} + +/// A default implementation of ProductFetching that uses StoreKit's standard product fetching. +@available(macOS 12.0, iOS 15.0, *) +public final class DefaultProductFetcher: ProductFetching { + /// Initializes a new DefaultProductFetcher instance. + public init() {} + + /// Fetches products using StoreKit's Product.products API. + /// - Parameter identifiers: An array of product identifiers to fetch. + /// - Returns: An array of subscription products. + /// - Throws: An error if the fetch operation fails. + public func products(for identifiers: [String]) async throws -> [any SubscriptionProduct] { + return try await Product.products(for: identifiers) + } +} diff --git a/Sources/Subscription/Managers/StorePurchaseManager.swift b/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift similarity index 84% rename from Sources/Subscription/Managers/StorePurchaseManager.swift rename to Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift index 47791eb22..b23f24548 100644 --- a/Sources/Subscription/Managers/StorePurchaseManager.swift +++ b/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift @@ -37,7 +37,16 @@ public enum StorePurchaseManagerError: Error { public protocol StorePurchaseManager { typealias TransactionJWS = String + /// Returns the available subscription options that DON'T include Free Trial periods. + /// - Returns: A `SubscriptionOptions` object containing the available subscription plans and pricing, + /// or `nil` if no options are available or cannot be fetched. func subscriptionOptions() async -> SubscriptionOptions? + + /// Returns the subscription options that include Free Trial periods. + /// - Returns: A `SubscriptionOptions` object containing subscription plans with free trial offers, + /// or `nil` if no free trial options are available or the user is not eligible. + func freeTrialSubscriptionOptions() async -> SubscriptionOptions? + var purchasedProductIDs: [String] { get } var purchaseQueue: [String] { get } var areProductsAvailable: Bool { get } @@ -61,7 +70,7 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM private let subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache private let subscriptionFeatureFlagger: FeatureFlaggerMapping? - @Published public private(set) var availableProducts: [Product] = [] + @Published public private(set) var availableProducts: [any SubscriptionProduct] = [] @Published public private(set) var purchasedProductIDs: [String] = [] @Published public private(set) var purchaseQueue: [String] = [] @@ -70,11 +79,15 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM private var transactionUpdates: Task? private var storefrontChanges: Task? + private var productFetcher: ProductFetching + public init(subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache, - subscriptionFeatureFlagger: FeatureFlaggerMapping? = nil) { + subscriptionFeatureFlagger: FeatureFlaggerMapping? = nil, + productFetcher: ProductFetching = DefaultProductFetcher()) { self.storeSubscriptionConfiguration = DefaultStoreSubscriptionConfiguration() self.subscriptionFeatureMappingCache = subscriptionFeatureMappingCache self.subscriptionFeatureFlagger = subscriptionFeatureFlagger + self.productFetcher = productFetcher transactionUpdates = observeTransactionUpdates() storefrontChanges = observeStorefrontChanges() } @@ -104,40 +117,13 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM } public func subscriptionOptions() async -> SubscriptionOptions? { - Logger.subscription.info("[AppStorePurchaseFlow] subscriptionOptions") - let products = availableProducts - let monthly = products.first(where: { $0.subscription?.subscriptionPeriod.unit == .month && $0.subscription?.subscriptionPeriod.value == 1 }) - let yearly = products.first(where: { $0.subscription?.subscriptionPeriod.unit == .year && $0.subscription?.subscriptionPeriod.value == 1 }) - guard let monthly, let yearly else { - Logger.subscription.error("[AppStorePurchaseFlow] No products found") - return nil - } - - let platform: SubscriptionPlatformName = { -#if os(iOS) - .ios -#else - .macos -#endif - }() - - let options = [SubscriptionOption(id: monthly.id, - cost: .init(displayPrice: monthly.displayPrice, recurrence: "monthly")), - SubscriptionOption(id: yearly.id, - cost: .init(displayPrice: yearly.displayPrice, recurrence: "yearly"))] - - let features: [SubscriptionFeature] - - if let featureFlagger = subscriptionFeatureFlagger, featureFlagger.isFeatureOn(.isLaunchedROW) || featureFlagger.isFeatureOn(.isLaunchedROWOverride) { - features = await subscriptionFeatureMappingCache.subscriptionFeatures(for: monthly.id).compactMap { SubscriptionFeature(name: $0) } - } else { - let allFeatures: [Entitlement.ProductName] = [.networkProtection, .dataBrokerProtection, .identityTheftRestoration] - features = allFeatures.compactMap { SubscriptionFeature(name: $0) } - } + let nonFreeTrialProducts = availableProducts.filter { !$0.hasFreeTrialOffer } + return await subscriptionOptions(for: nonFreeTrialProducts) + } - return SubscriptionOptions(platform: platform, - options: options, - features: features) + public func freeTrialSubscriptionOptions() async -> SubscriptionOptions? { + let freeTrialProducts = availableProducts.filter { $0.hasFreeTrialOffer } + return await subscriptionOptions(for: freeTrialProducts) } @MainActor @@ -165,10 +151,10 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM self.currentStorefrontRegion = storefrontRegion let applicableProductIdentifiers = storeSubscriptionConfiguration.subscriptionIdentifiers(for: storefrontRegion) - let availableProducts = try await Product.products(for: applicableProductIdentifiers) + let availableProducts = try await productFetcher.products(for: applicableProductIdentifiers) Logger.subscription.info("[StorePurchaseManager] updateAvailableProducts fetched \(availableProducts.count) products for \(storefrontCountryCode ?? "", privacy: .public)") - if self.availableProducts != availableProducts { + if Set(availableProducts.map { $0.id }) != Set(self.availableProducts.map { $0.id }) { self.availableProducts = availableProducts // Update cached subscription features mapping @@ -298,6 +284,40 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM } } + private func subscriptionOptions(for products: [any SubscriptionProduct]) async -> SubscriptionOptions? { + Logger.subscription.info("[AppStorePurchaseFlow] subscriptionOptions") + let monthly = products.first(where: { $0.isMonthly }) + let yearly = products.first(where: { $0.isYearly }) + guard let monthly, let yearly else { + Logger.subscription.error("[AppStorePurchaseFlow] No products found") + return nil + } + + let platform: SubscriptionPlatformName = { +#if os(iOS) + .ios +#else + .macos +#endif + }() + + let options: [SubscriptionOption] = await [.init(from: monthly, withRecurrence: "monthly"), + .init(from: yearly, withRecurrence: "yearly")] + + let features: [SubscriptionFeature] + + if let featureFlagger = subscriptionFeatureFlagger, featureFlagger.isFeatureOn(.isLaunchedROW) || featureFlagger.isFeatureOn(.isLaunchedROWOverride) { + features = await subscriptionFeatureMappingCache.subscriptionFeatures(for: monthly.id).compactMap { SubscriptionFeature(name: $0) } + } else { + let allFeatures: [Entitlement.ProductName] = [.networkProtection, .dataBrokerProtection, .identityTheftRestoration] + features = allFeatures.compactMap { SubscriptionFeature(name: $0) } + } + + return SubscriptionOptions(platform: platform, + options: options, + features: features) + } + private func checkVerified(_ result: VerificationResult) throws -> T { // Check whether the JWS passes StoreKit verification. switch result { @@ -337,6 +357,24 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM } } +@available(macOS 12.0, iOS 15.0, *) +private extension SubscriptionOption { + + init(from product: any SubscriptionProduct, withRecurrence recurrence: String) async { + var offer: SubscriptionOptionOffer? + + if let introOffer = product.introductoryOffer, introOffer.isFreeTrial { + + let durationInDays = introOffer.periodInDays + let isUserEligible = await product.isEligibleForIntroOffer + + offer = .init(type: .freeTrial, id: introOffer.id ?? "", displayPrice: introOffer.displayPrice, durationInDays: durationInDays, isUserEligible: isUserEligible) + } + + self.init(id: product.id, cost: .init(displayPrice: product.displayPrice, recurrence: recurrence), offer: offer) + } +} + public extension UserDefaults { enum Constants { diff --git a/Sources/Subscription/Managers/StorePurchaseManager/SubscriptionProduct.swift b/Sources/Subscription/Managers/StorePurchaseManager/SubscriptionProduct.swift new file mode 100644 index 000000000..4c0e64faa --- /dev/null +++ b/Sources/Subscription/Managers/StorePurchaseManager/SubscriptionProduct.swift @@ -0,0 +1,101 @@ +// +// SubscriptionProduct.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import StoreKit + +/// A protocol that defines the core functionality and properties of a subscription product. +/// Conforming types must provide information about pricing, description, and subscription terms. +@available(macOS 12.0, iOS 15.0, *) +public protocol SubscriptionProduct: Equatable { + /// The unique identifier of the product. + var id: String { get } + + /// The user-facing name of the product. + var displayName: String { get } + + /// The formatted price that should be displayed to users. + var displayPrice: String { get } + + /// A detailed description of the product. + var description: String { get } + + /// Indicates whether this is a monthly subscription. + var isMonthly: Bool { get } + + /// Indicates whether this is a yearly subscription. + var isYearly: Bool { get } + + /// The introductory offer associated with this subscription, if any. + var introductoryOffer: SubscriptionProductIntroductoryOffer? { get } + + /// Indicates whether this subscription has a Free Trial offer available. + var hasFreeTrialOffer: Bool { get } + + /// Asynchronously determines whether the user is eligible for an introductory offer. + var isEligibleForIntroOffer: Bool { get async } + + /// Initiates a purchase of the subscription with the specified options. + /// - Parameter options: A set of options to configure the purchase. + /// - Returns: The result of the purchase attempt. + /// - Throws: An error if the purchase fails. + func purchase(options: Set) async throws -> Product.PurchaseResult +} + +/// Extends StoreKit's Product to conform to SubscriptionProduct. +@available(macOS 12.0, iOS 15.0, *) +extension Product: SubscriptionProduct { + /// Determines if this is a monthly subscription by checking if the subscription period + /// is exactly one month. + public var isMonthly: Bool { + guard let subscription else { return false } + return subscription.subscriptionPeriod.unit == .month && + subscription.subscriptionPeriod.value == 1 + } + + /// Determines if this is a yearly subscription by checking if the subscription period + /// is exactly one year. + public var isYearly: Bool { + guard let subscription else { return false } + return subscription.subscriptionPeriod.unit == .year && + subscription.subscriptionPeriod.value == 1 + } + + /// Returns the introductory offer for this subscription if available. + public var introductoryOffer: (any SubscriptionProductIntroductoryOffer)? { + subscription?.introductoryOffer + } + + /// Indicates whether this subscription has a Free Trial offer. + public var hasFreeTrialOffer: Bool { + return subscription?.introductoryOffer?.isFreeTrial ?? false + } + + /// Asynchronously checks if the user is eligible for an introductory offer. + public var isEligibleForIntroOffer: Bool { + get async { + guard let subscription else { return false } + return await subscription.isEligibleForIntroOffer + } + } + + /// Implements Equatable by comparing product IDs. + public static func == (lhs: Product, rhs: Product) -> Bool { + return lhs.id == rhs.id + } +} diff --git a/Sources/Subscription/Managers/StorePurchaseManager/SubscriptionProductIntroductoryOffer.swift b/Sources/Subscription/Managers/StorePurchaseManager/SubscriptionProductIntroductoryOffer.swift new file mode 100644 index 000000000..63bbd7a92 --- /dev/null +++ b/Sources/Subscription/Managers/StorePurchaseManager/SubscriptionProductIntroductoryOffer.swift @@ -0,0 +1,67 @@ +// +// SubscriptionProductIntroductoryOffer.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import StoreKit + +/// A protocol that defines the properties of an introductory offer for a subscription product. +/// Use this protocol to represent trial periods, introductory prices, or other special offers. +@available(macOS 12.0, iOS 15.0, *) +public protocol SubscriptionProductIntroductoryOffer { + /// The unique identifier of the introductory offer. + var id: String? { get } + + /// The formatted price of the offer that should be displayed to users. + var displayPrice: String { get } + + /// The duration of the offer in days. + var periodInDays: Int { get } + + /// Indicates whether this offer represents a free trial period. + var isFreeTrial: Bool { get } +} + +/// Extends StoreKit's Product.SubscriptionOffer to conform to SubscriptionProductIntroductoryOffer. +@available(macOS 12.0, iOS 15.0, *) +extension Product.SubscriptionOffer: SubscriptionProductIntroductoryOffer { + /// Calculates the total number of days in the offer period by multiplying + /// the base period length by the period count. + public var periodInDays: Int { + period.periodInDays * periodCount + } + + /// Determines if this offer represents a free trial based on the payment mode. + public var isFreeTrial: Bool { + paymentMode == .freeTrial + } +} + +@available(macOS 12.0, iOS 15.0, *) +private extension Product.SubscriptionPeriod { + + var periodInDays: Int { + switch unit { + case .day: return value + case .week: return value * 7 + case .month: return value * 30 + case .year: return value * 365 + @unknown default: + return value + } + } +} diff --git a/Sources/Subscription/StoreSubscriptionConfiguration.swift b/Sources/Subscription/StoreSubscriptionConfiguration.swift index 8dacf7db4..be3347c62 100644 --- a/Sources/Subscription/StoreSubscriptionConfiguration.swift +++ b/Sources/Subscription/StoreSubscriptionConfiguration.swift @@ -44,7 +44,9 @@ final class DefaultStoreSubscriptionConfiguration: StoreSubscriptionConfiguratio appIdentifier: "com.duckduckgo.mobile.ios.alpha", environment: .staging, identifiersByRegion: [.usa: ["ios.subscription.1month", - "ios.subscription.1year"], + "ios.subscription.1year", + "ios.subscription.1month.freetrial.dev", + "ios.subscription.1year.freetrial.dev"], .restOfWorld: ["ios.subscription.1month.row", "ios.subscription.1year.row"]]), // macOS debug build diff --git a/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift b/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift index e326fd720..3d5c34f63 100644 --- a/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift +++ b/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift @@ -20,12 +20,14 @@ import Foundation import Subscription public final class StorePurchaseManagerMock: StorePurchaseManager { + public var purchasedProductIDs: [String] = [] public var purchaseQueue: [String] = [] public var areProductsAvailable: Bool = false public var currentStorefrontRegion: SubscriptionRegion = .usa public var subscriptionOptionsResult: SubscriptionOptions? + public var freeTrialSubscriptionOptionsResult: SubscriptionOptions? public var syncAppleIDAccountResultError: Error? public var mostRecentTransactionResult: String? @@ -44,6 +46,10 @@ public final class StorePurchaseManagerMock: StorePurchaseManager { subscriptionOptionsResult } + public func freeTrialSubscriptionOptions() async -> SubscriptionOptions? { + freeTrialSubscriptionOptionsResult + } + public func syncAppleIDAccount() async throws { if let syncAppleIDAccountResultError { throw syncAppleIDAccountResultError diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift b/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift index c4612a9bd..ef39c4d04 100644 --- a/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift +++ b/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift @@ -21,11 +21,16 @@ import Subscription public final class SubscriptionFeatureMappingCacheMock: SubscriptionFeatureMappingCache { + public var didCallSubscriptionFeatures = false + public var lastCalledSubscriptionId: String? + public var mapping: [String: [Entitlement.ProductName]] = [:] public init() { } public func subscriptionFeatures(for subscriptionIdentifier: String) async -> [Entitlement.ProductName] { + didCallSubscriptionFeatures = true + lastCalledSubscriptionId = subscriptionIdentifier return mapping[subscriptionIdentifier] ?? [] } } diff --git a/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift b/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift index 3fb695733..8ea41fa66 100644 --- a/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift +++ b/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift @@ -23,12 +23,14 @@ import SubscriptionTestingUtilities final class SubscriptionOptionsTests: XCTestCase { func testEncoding() throws { + let monthlySubscriptionOffer = SubscriptionOptionOffer(type: .freeTrial, id: "1", displayPrice: "$0.00", durationInDays: 7, isUserEligible: true) + let yearlySubscriptionOffer = SubscriptionOptionOffer(type: .freeTrial, id: "2", displayPrice: "$0.00", durationInDays: 7, isUserEligible: true) let subscriptionOptions = SubscriptionOptions(platform: .macos, options: [ SubscriptionOption(id: "1", - cost: SubscriptionOptionCost(displayPrice: "9 USD", recurrence: "monthly")), + cost: SubscriptionOptionCost(displayPrice: "9 USD", recurrence: "monthly"), offer: monthlySubscriptionOffer), SubscriptionOption(id: "2", - cost: SubscriptionOptionCost(displayPrice: "99 USD", recurrence: "yearly")) + cost: SubscriptionOptionCost(displayPrice: "99 USD", recurrence: "yearly"), offer: yearlySubscriptionOffer) ], features: [ SubscriptionFeature(name: .networkProtection), @@ -60,14 +62,28 @@ final class SubscriptionOptionsTests: XCTestCase { "displayPrice" : "9 USD", "recurrence" : "monthly" }, - "id" : "1" + "id" : "1", + "offer" : { + "displayPrice" : "$0.00", + "durationInDays" : 7, + "id" : "1", + "isUserEligible" : true, + "type" : "freeTrial" + } }, { "cost" : { "displayPrice" : "99 USD", "recurrence" : "yearly" }, - "id" : "2" + "id" : "2", + "offer" : { + "displayPrice" : "$0.00", + "durationInDays" : 7, + "id" : "2", + "isUserEligible" : true, + "type" : "freeTrial" + } } ], "platform" : "macos" diff --git a/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift b/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift new file mode 100644 index 000000000..730134ec3 --- /dev/null +++ b/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift @@ -0,0 +1,542 @@ +// +// StorePurchaseManagerTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Subscription +import SubscriptionTestingUtilities +import StoreKit + +final class StorePurchaseManagerTests: XCTestCase { + + private var sut: StorePurchaseManager! + private var mockCache: SubscriptionFeatureMappingCacheMock! + private var mockProductFetcher: MockProductFetcher! + private var mockFeatureFlagger: MockFeatureFlagger! + + override func setUpWithError() throws { + mockCache = SubscriptionFeatureMappingCacheMock() + mockProductFetcher = MockProductFetcher() + mockFeatureFlagger = MockFeatureFlagger() + sut = DefaultStorePurchaseManager(subscriptionFeatureMappingCache: mockCache, + subscriptionFeatureFlagger: mockFeatureFlagger, + productFetcher: mockProductFetcher) + } + + func testSubscriptionOptionsReturnsOnlyNonTrialProducts() async { + // Given + let monthlyProduct = MockSubscriptionProduct( + id: "com.test.monthly", + displayName: "Monthly Plan", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: false + ) + + let yearlyProduct = MockSubscriptionProduct( + id: "com.test.yearly", + displayName: "Yearly Plan", + displayPrice: "$99.99", + isYearly: true, + hasFreeTrialOffer: false + ) + + let monthlyTrialProduct = MockSubscriptionProduct( + id: "com.test.monthly.trial", + displayName: "Monthly Plan with Trial", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: true, + introOffer: MockIntroductoryOffer( + id: "trial1", + displayPrice: "Free", + periodInDays: 7, + isFreeTrial: true + ) + ) + + mockProductFetcher.mockProducts = [monthlyProduct, yearlyProduct, monthlyTrialProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.subscriptionOptions() + + // Then + XCTAssertNotNil(subscriptionOptions) + XCTAssertEqual(subscriptionOptions?.options.count, 2) + + let productIds = subscriptionOptions?.options.map { $0.id } ?? [] + XCTAssertTrue(productIds.contains("com.test.monthly")) + XCTAssertTrue(productIds.contains("com.test.yearly")) + XCTAssertFalse(productIds.contains("com.test.monthly.trial")) + } + + func testFreeTrialSubscriptionOptionsReturnsOnlyTrialProducts() async { + // Given + let monthlyTrialProduct = MockSubscriptionProduct( + id: "com.test.monthly.trial", + displayName: "Monthly Plan with Trial", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: true, + introOffer: MockIntroductoryOffer( + id: "trial1", + displayPrice: "Free", + periodInDays: 7, + isFreeTrial: true + ), + isEligibleForIntroOffer: true + ) + + let yearlyTrialProduct = MockSubscriptionProduct( + id: "com.test.yearly.trial", + displayName: "Yearly Plan with Trial", + displayPrice: "$99.99", + isYearly: true, + hasFreeTrialOffer: true, + introOffer: MockIntroductoryOffer( + id: "trial2", + displayPrice: "Free", + periodInDays: 7, + isFreeTrial: true + ), + isEligibleForIntroOffer: true + ) + + let regularProduct = MockSubscriptionProduct( + id: "com.test.regular", + displayName: "Regular Plan", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: false + ) + + mockProductFetcher.mockProducts = [monthlyTrialProduct, yearlyTrialProduct, regularProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.freeTrialSubscriptionOptions() + + // Then + XCTAssertNotNil(subscriptionOptions) + XCTAssertEqual(subscriptionOptions?.options.count, 2) + + let productIds = subscriptionOptions?.options.map { $0.id } ?? [] + XCTAssertTrue(productIds.contains("com.test.monthly.trial")) + XCTAssertTrue(productIds.contains("com.test.yearly.trial")) + XCTAssertFalse(productIds.contains("com.test.regular")) + } + + func testSubscriptionOptionsReturnsNilWhenNoValidProductPairExists() async { + // Given + let monthlyProduct = MockSubscriptionProduct( + id: "com.test.monthly", + displayName: "Monthly Plan", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: false + ) + + mockProductFetcher.mockProducts = [monthlyProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.subscriptionOptions() + + // Then + XCTAssertNil(subscriptionOptions) + } + + func testFreeTrialSubscriptionOptionsReturnsNilWhenNoValidProductPairExists() async { + // Given + let monthlyTrialProduct = MockSubscriptionProduct( + id: "com.test.monthly.trial", + displayName: "Monthly Plan with Trial", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: true, + introOffer: MockIntroductoryOffer( + id: "trial1", + displayPrice: "Free", + periodInDays: 7, + isFreeTrial: true + ) + ) + + mockProductFetcher.mockProducts = [monthlyTrialProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.freeTrialSubscriptionOptions() + + // Then + XCTAssertNil(subscriptionOptions) + } + + func testSubscriptionOptionsIncludesCorrectDetails() async { + // Given + let monthlyProduct = MockSubscriptionProduct( + id: "com.test.monthly", + displayName: "Monthly Plan", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: false + ) + + let yearlyProduct = MockSubscriptionProduct( + id: "com.test.yearly", + displayName: "Yearly Plan", + displayPrice: "$99.99", + isYearly: true, + hasFreeTrialOffer: false + ) + + mockProductFetcher.mockProducts = [monthlyProduct, yearlyProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.subscriptionOptions() + + // Then + XCTAssertNotNil(subscriptionOptions) + XCTAssertEqual(subscriptionOptions?.options.count, 2) + + let monthlyOption = subscriptionOptions?.options.first { $0.id == "com.test.monthly" } + XCTAssertNotNil(monthlyOption) + XCTAssertEqual(monthlyOption?.cost.displayPrice, "$9.99") + XCTAssertEqual(monthlyOption?.cost.recurrence, "monthly") + XCTAssertNil(monthlyOption?.offer) + + let yearlyOption = subscriptionOptions?.options.first { $0.id == "com.test.yearly" } + XCTAssertNotNil(yearlyOption) + XCTAssertEqual(yearlyOption?.cost.displayPrice, "$99.99") + XCTAssertEqual(yearlyOption?.cost.recurrence, "yearly") + XCTAssertNil(yearlyOption?.offer) + } + + func testFreeTrialSubscriptionOptionsIncludesCorrectTrialDetails() async { + // Given + let monthlyTrialProduct = MockSubscriptionProduct( + id: "com.test.monthly.trial", + displayName: "Monthly Plan with Trial", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: true, + introOffer: MockIntroductoryOffer( + id: "trial1", + displayPrice: "$0.00", + periodInDays: 7, + isFreeTrial: true + ), + isEligibleForIntroOffer: true + ) + + let yearlyTrialProduct = MockSubscriptionProduct( + id: "com.test.yearly.trial", + displayName: "Yearly Plan with Trial", + displayPrice: "$99.99", + isYearly: true, + hasFreeTrialOffer: true, + introOffer: MockIntroductoryOffer( + id: "trial2", + displayPrice: "$0.00", + periodInDays: 14, + isFreeTrial: true + ), + isEligibleForIntroOffer: true + ) + + mockProductFetcher.mockProducts = [monthlyTrialProduct, yearlyTrialProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.freeTrialSubscriptionOptions() + + // Then + XCTAssertNotNil(subscriptionOptions) + + let monthlyOption = subscriptionOptions?.options.first { $0.id == "com.test.monthly.trial" } + XCTAssertNotNil(monthlyOption) + XCTAssertNotNil(monthlyOption?.offer) + XCTAssertEqual(monthlyOption?.offer?.type, .freeTrial) + XCTAssertEqual(monthlyOption?.offer?.displayPrice, "$0.00") + XCTAssertEqual(monthlyOption?.offer?.durationInDays, 7) + XCTAssertTrue(monthlyOption?.offer?.isUserEligible ?? false) + + let yearlyOption = subscriptionOptions?.options.first { $0.id == "com.test.yearly.trial" } + XCTAssertNotNil(yearlyOption) + XCTAssertNotNil(yearlyOption?.offer) + XCTAssertEqual(yearlyOption?.offer?.type, .freeTrial) + XCTAssertEqual(yearlyOption?.offer?.displayPrice, "$0.00") + XCTAssertEqual(yearlyOption?.offer?.durationInDays, 14) + XCTAssertTrue(yearlyOption?.offer?.isUserEligible ?? false) + } + + func testUpdateAvailableProductsSuccessfully() async { + // Given + let monthlyProduct = createMonthlyProduct() + let yearlyProduct = createYearlyProduct() + mockProductFetcher.mockProducts = [monthlyProduct, yearlyProduct] + + // When + await sut.updateAvailableProducts() + + // Then + let products = (sut as? DefaultStorePurchaseManager)?.availableProducts ?? [] + XCTAssertEqual(products.count, 2) + XCTAssertTrue(products.contains(where: { $0.id == monthlyProduct.id })) + XCTAssertTrue(products.contains(where: { $0.id == yearlyProduct.id })) + } + + func testUpdateAvailableProductsWithError() async { + // Given + mockProductFetcher.fetchError = MockProductError.fetchFailed + + // When + await sut.updateAvailableProducts() + + // Then + let products = (sut as? DefaultStorePurchaseManager)?.availableProducts ?? [] + XCTAssertTrue(products.isEmpty) + } + + func testUpdateAvailableProductsWithDifferentRegions() async { + // Given + let usaMonthlyProduct = MockSubscriptionProduct( + id: "com.test.usa.monthly", + displayName: "USA Monthly Plan", + displayPrice: "$9.99", + isMonthly: true + ) + let usaYearlyProduct = MockSubscriptionProduct( + id: "com.test.usa.yearly", + displayName: "USA Yearly Plan", + displayPrice: "$99.99", + isYearly: true + ) + + let rowMonthlyProduct = MockSubscriptionProduct( + id: "com.test.row.monthly", + displayName: "ROW Monthly Plan", + displayPrice: "€8.99", + isMonthly: true + ) + let rowYearlyProduct = MockSubscriptionProduct( + id: "com.test.row.yearly", + displayName: "ROW Yearly Plan", + displayPrice: "€89.99", + isYearly: true + ) + + // Set USA products initially + mockProductFetcher.mockProducts = [usaMonthlyProduct, usaYearlyProduct] + mockFeatureFlagger.enabledFeatures = [] // No ROW features enabled - defaults to USA + + // When - Update for USA region + await sut.updateAvailableProducts() + + // Then - Verify USA products + let usaProducts = (sut as? DefaultStorePurchaseManager)?.availableProducts ?? [] + XCTAssertEqual(usaProducts.count, 2) + XCTAssertEqual((sut as? DefaultStorePurchaseManager)?.currentStorefrontRegion, .usa) + XCTAssertTrue(usaProducts.contains(where: { $0.id == "com.test.usa.monthly" })) + XCTAssertTrue(usaProducts.contains(where: { $0.id == "com.test.usa.yearly" })) + + // When - Switch to ROW region + mockProductFetcher.mockProducts = [rowMonthlyProduct, rowYearlyProduct] + mockFeatureFlagger.enabledFeatures = [.isLaunchedROW, .usePrivacyProROWRegionOverride] + await sut.updateAvailableProducts() + + // Then - Verify ROW products + let rowProducts = (sut as? DefaultStorePurchaseManager)?.availableProducts ?? [] + XCTAssertEqual(rowProducts.count, 2) + XCTAssertEqual((sut as? DefaultStorePurchaseManager)?.currentStorefrontRegion, .restOfWorld) + XCTAssertTrue(rowProducts.contains(where: { $0.id == "com.test.row.monthly" })) + XCTAssertTrue(rowProducts.contains(where: { $0.id == "com.test.row.yearly" })) + + // Verify pricing differences + let usaMonthlyPrice = usaProducts.first(where: { $0.isMonthly })?.displayPrice + let rowMonthlyPrice = rowProducts.first(where: { $0.isMonthly })?.displayPrice + XCTAssertEqual(usaMonthlyPrice, "$9.99") + XCTAssertEqual(rowMonthlyPrice, "€8.99") + } + + func testUpdateAvailableProductsUpdatesFeatureMapping() async { + // Given + let monthlyProduct = createMonthlyProduct() + let yearlyProduct = createYearlyProduct() + mockProductFetcher.mockProducts = [monthlyProduct, yearlyProduct] + + // When + await sut.updateAvailableProducts() + + // Then + XCTAssertTrue(mockCache.didCallSubscriptionFeatures) + XCTAssertEqual(mockCache.lastCalledSubscriptionId, yearlyProduct.id) + } +} + +private final class MockProductFetcher: ProductFetching { + var mockProducts: [any SubscriptionProduct] = [] + var fetchError: Error? + var fetchCount: Int = 0 + + public func products(for identifiers: [String]) async throws -> [any SubscriptionProduct] { + fetchCount += 1 + if let error = fetchError { + throw error + } + return mockProducts + } +} + +private enum MockProductError: Error { + case fetchFailed +} + +private extension StorePurchaseManagerTests { + func createMonthlyProduct(withTrial: Bool = false) -> MockSubscriptionProduct { + MockSubscriptionProduct( + id: "com.test.monthly\(withTrial ? ".trial" : "")", + displayName: "Monthly Plan\(withTrial ? " with Trial" : "")", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: withTrial, + introOffer: withTrial ? MockIntroductoryOffer( + id: "trial1", + displayPrice: "Free", + periodInDays: 7, + isFreeTrial: true + ) : nil, + isEligibleForIntroOffer: withTrial + ) + } + + func createYearlyProduct(withTrial: Bool = false) -> MockSubscriptionProduct { + MockSubscriptionProduct( + id: "com.test.yearly\(withTrial ? ".trial" : "")", + displayName: "Yearly Plan\(withTrial ? " with Trial" : "")", + displayPrice: "$99.99", + isYearly: true, + hasFreeTrialOffer: withTrial, + introOffer: withTrial ? MockIntroductoryOffer( + id: "trial2", + displayPrice: "Free", + periodInDays: 14, + isFreeTrial: true + ) : nil, + isEligibleForIntroOffer: withTrial + ) + } +} + +private class MockSubscriptionProduct: SubscriptionProduct { + let id: String + let displayName: String + let displayPrice: String + let description: String + let isMonthly: Bool + let isYearly: Bool + let hasFreeTrialOffer: Bool + private let mockIntroOffer: MockIntroductoryOffer? + private let mockIsEligibleForIntroOffer: Bool + + init(id: String, + displayName: String = "Mock Product", + displayPrice: String = "$4.99", + description: String = "Mock Description", + isMonthly: Bool = false, + isYearly: Bool = false, + hasFreeTrialOffer: Bool = false, + introOffer: MockIntroductoryOffer? = nil, + isEligibleForIntroOffer: Bool = false) { + self.id = id + self.displayName = displayName + self.displayPrice = displayPrice + self.description = description + self.isMonthly = isMonthly + self.isYearly = isYearly + self.hasFreeTrialOffer = hasFreeTrialOffer + self.mockIntroOffer = introOffer + self.mockIsEligibleForIntroOffer = isEligibleForIntroOffer + } + + var introductoryOffer: SubscriptionProductIntroductoryOffer? { + return mockIntroOffer + } + + var isEligibleForIntroOffer: Bool { + get async { + return mockIsEligibleForIntroOffer + } + } + + func purchase(options: Set) async throws -> Product.PurchaseResult { + fatalError("Not implemented for tests") + } + + static func == (lhs: MockSubscriptionProduct, rhs: MockSubscriptionProduct) -> Bool { + return lhs.id == rhs.id + } +} + +private struct MockIntroductoryOffer: SubscriptionProductIntroductoryOffer { + var id: String? + var displayPrice: String + var periodInDays: Int + var isFreeTrial: Bool +} + +private class MockFeatureFlagger: FeatureFlaggerMapping { + var enabledFeatures: Set = [] + + init(enabledFeatures: Set = []) { + self.enabledFeatures = enabledFeatures + super.init(mapping: {_ in true}) + } + + override func isFeatureOn(_ feature: SubscriptionFeatureFlags) -> Bool { + return enabledFeatures.contains(feature) + } +} + +private class MockStoreSubscriptionConfiguration: StoreSubscriptionConfiguration { + let usaIdentifiers = ["com.test.usa.monthly", "com.test.usa.yearly"] + let rowIdentifiers = ["com.test.row.monthly", "com.test.row.yearly"] + + var allSubscriptionIdentifiers: [String] { + usaIdentifiers + rowIdentifiers + } + + func subscriptionIdentifiers(for region: SubscriptionRegion) -> [String] { + switch region { + case .usa: + return usaIdentifiers + case .restOfWorld: + return rowIdentifiers + } + } + + func subscriptionIdentifiers(for country: String) -> [String] { + switch country.uppercased() { + case "USA": + return usaIdentifiers + default: + return rowIdentifiers + } + } +}