diff --git a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift index 365cb3778..ffada4f02 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift @@ -164,6 +164,8 @@ public enum PrivacyProSubfeature: String, Equatable, PrivacySubfeature { case isLaunchedOverrideStripe case useUnifiedFeedback case setAccessTokenCookieForSubscriptionDomains + case isLaunchedROW + case isLaunchedROWOverride } public enum SslCertificatesSubfeature: String, PrivacySubfeature { diff --git a/Sources/Subscription/API/Model/Entitlement.swift b/Sources/Subscription/API/Model/Entitlement.swift index c90e7342c..1d8eb645a 100644 --- a/Sources/Subscription/API/Model/Entitlement.swift +++ b/Sources/Subscription/API/Model/Entitlement.swift @@ -25,6 +25,7 @@ public struct Entitlement: Codable, Equatable { case networkProtection = "Network Protection" case dataBrokerProtection = "Data Broker Protection" case identityTheftRestoration = "Identity Theft Restoration" + case identityTheftRestorationGlobal = "Global Identity Theft Restoration" case unknown public init(from decoder: Decoder) throws { diff --git a/Sources/Subscription/API/SubscriptionEndpointService.swift b/Sources/Subscription/API/SubscriptionEndpointService.swift index 7898bbddb..552c28d4a 100644 --- a/Sources/Subscription/API/SubscriptionEndpointService.swift +++ b/Sources/Subscription/API/SubscriptionEndpointService.swift @@ -27,6 +27,10 @@ public struct GetProductsItem: Decodable { public let currency: String } +public struct GetSubscriptionFeaturesResponse: Decodable { + public let features: [Entitlement.ProductName] +} + public struct GetCustomerPortalURLResponse: Decodable { public let customerPortalUrl: String } @@ -47,6 +51,7 @@ public protocol SubscriptionEndpointService { func getSubscription(accessToken: String, cachePolicy: APICachePolicy) async -> Result func signOut() func getProducts() async -> Result<[GetProductsItem], APIServiceError> + func getSubscriptionFeatures(for subscriptionID: String) async -> Result func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result func confirmPurchase(accessToken: String, signature: String) async -> Result } @@ -137,6 +142,12 @@ public struct DefaultSubscriptionEndpointService: SubscriptionEndpointService { // MARK: - + public func getSubscriptionFeatures(for subscriptionID: String) async -> Result { + await apiService.executeAPICall(method: "GET", endpoint: "products/\(subscriptionID)/features", headers: nil, body: nil) + } + + // MARK: - + public func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result { var headers = apiService.makeAuthorizationHeader(for: accessToken) headers["externalAccountId"] = externalID diff --git a/Sources/Subscription/FeatureFlags/FeatureFlaggerMapping.swift b/Sources/Subscription/FeatureFlags/FeatureFlaggerMapping.swift new file mode 100644 index 000000000..f2541131e --- /dev/null +++ b/Sources/Subscription/FeatureFlags/FeatureFlaggerMapping.swift @@ -0,0 +1,33 @@ +// +// FeatureFlaggerMapping.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 + +open class FeatureFlaggerMapping { + public typealias Mapping = (_ feature: Feature) -> Bool + + private let isFeatureEnabledMapping: Mapping + + public init(mapping: @escaping Mapping) { + isFeatureEnabledMapping = mapping + } + + public func isFeatureOn(_ feature: Feature) -> Bool { + return isFeatureEnabledMapping(feature) + } +} diff --git a/Sources/Subscription/Flows/Models/SubscriptionEnvironmentNames.swift b/Sources/Subscription/FeatureFlags/SubscriptionFeatureFlags.swift similarity index 54% rename from Sources/Subscription/Flows/Models/SubscriptionEnvironmentNames.swift rename to Sources/Subscription/FeatureFlags/SubscriptionFeatureFlags.swift index 540c0c2e8..104d20066 100644 --- a/Sources/Subscription/Flows/Models/SubscriptionEnvironmentNames.swift +++ b/Sources/Subscription/FeatureFlags/SubscriptionFeatureFlags.swift @@ -1,5 +1,5 @@ // -// SubscriptionEnvironmentNames.swift +// SubscriptionFeatureFlags.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -18,18 +18,21 @@ import Foundation -public enum SubscriptionFeatureName: String, CaseIterable { - case privateBrowsing = "private-browsing" - case privateSearch = "private-search" - case emailProtection = "email-protection" - case appTrackingProtection = "app-tracking-protection" - case vpn = "vpn" - case personalInformationRemoval = "personal-information-removal" - case identityTheftRestoration = "identity-theft-restoration" +public enum SubscriptionFeatureFlags { + case isLaunchedROW + case isLaunchedROWOverride + case usePrivacyProUSARegionOverride + case usePrivacyProROWRegionOverride } -public enum SubscriptionPlatformName: String { - case ios - case macos - case stripe +public extension SubscriptionFeatureFlags { + + var defaultState: Bool { + switch self { + case .isLaunchedROW, .isLaunchedROWOverride: + return true + case .usePrivacyProUSARegionOverride, .usePrivacyProROWRegionOverride: + return false + } + } } diff --git a/Sources/Subscription/Flows/Models/SubscriptionOptions.swift b/Sources/Subscription/Flows/Models/SubscriptionOptions.swift index ca0347384..5544cf680 100644 --- a/Sources/Subscription/Flows/Models/SubscriptionOptions.swift +++ b/Sources/Subscription/Flows/Models/SubscriptionOptions.swift @@ -19,21 +19,34 @@ import Foundation public struct SubscriptionOptions: Encodable, Equatable { - let platform: String + let platform: SubscriptionPlatformName let options: [SubscriptionOption] let features: [SubscriptionFeature] + public static var empty: SubscriptionOptions { - let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) } + let features = [SubscriptionFeature(name: .networkProtection), + SubscriptionFeature(name: .dataBrokerProtection), + SubscriptionFeature(name: .identityTheftRestoration)] let platform: SubscriptionPlatformName #if os(iOS) platform = .ios #else platform = .macos #endif - return SubscriptionOptions(platform: platform.rawValue, options: [], features: features) + return SubscriptionOptions(platform: platform, options: [], features: features) + } + + public func withoutPurchaseOptions() -> Self { + SubscriptionOptions(platform: platform, options: [], features: features) } } +public enum SubscriptionPlatformName: String, Encodable { + case ios + case macos + case stripe +} + public struct SubscriptionOption: Encodable, Equatable { let id: String let cost: SubscriptionOptionCost @@ -45,5 +58,5 @@ struct SubscriptionOptionCost: Encodable, Equatable { } public struct SubscriptionFeature: Encodable, Equatable { - let name: String + let name: Entitlement.ProductName } diff --git a/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift b/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift index 3912d96a2..43e0448e7 100644 --- a/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift +++ b/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift @@ -71,9 +71,11 @@ public final class DefaultStripePurchaseFlow: StripePurchaseFlow { cost: cost) } - let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) } + let features = [SubscriptionFeature(name: .networkProtection), + SubscriptionFeature(name: .dataBrokerProtection), + SubscriptionFeature(name: .identityTheftRestoration)] - return .success(SubscriptionOptions(platform: SubscriptionPlatformName.stripe.rawValue, + return .success(SubscriptionOptions(platform: SubscriptionPlatformName.stripe, options: options, features: features)) } diff --git a/Sources/Subscription/Managers/StorePurchaseManager.swift b/Sources/Subscription/Managers/StorePurchaseManager.swift index 24ebc3d31..47791eb22 100644 --- a/Sources/Subscription/Managers/StorePurchaseManager.swift +++ b/Sources/Subscription/Managers/StorePurchaseManager.swift @@ -41,6 +41,7 @@ public protocol StorePurchaseManager { var purchasedProductIDs: [String] { get } var purchaseQueue: [String] { get } var areProductsAvailable: Bool { get } + var currentStorefrontRegion: SubscriptionRegion { get } @MainActor func syncAppleIDAccount() async throws @MainActor func updateAvailableProducts() async @@ -56,21 +57,24 @@ public protocol StorePurchaseManager { @available(macOS 12.0, iOS 15.0, *) public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseManager { - let productIdentifiers = ["ios.subscription.1month", "ios.subscription.1year", - "subscription.1month", "subscription.1year", - "review.subscription.1month", "review.subscription.1year", - "tf.sandbox.subscription.1month", "tf.sandbox.subscription.1year", - "ddg.privacy.pro.monthly.renews.us", "ddg.privacy.pro.yearly.renews.us"] + private let storeSubscriptionConfiguration: StoreSubscriptionConfiguration + private let subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache + private let subscriptionFeatureFlagger: FeatureFlaggerMapping? @Published public private(set) var availableProducts: [Product] = [] @Published public private(set) var purchasedProductIDs: [String] = [] @Published public private(set) var purchaseQueue: [String] = [] public var areProductsAvailable: Bool { !availableProducts.isEmpty } + public private(set) var currentStorefrontRegion: SubscriptionRegion = .usa private var transactionUpdates: Task? private var storefrontChanges: Task? - public init() { + public init(subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache, + subscriptionFeatureFlagger: FeatureFlaggerMapping? = nil) { + self.storeSubscriptionConfiguration = DefaultStoreSubscriptionConfiguration() + self.subscriptionFeatureMappingCache = subscriptionFeatureMappingCache + self.subscriptionFeatureFlagger = subscriptionFeatureFlagger transactionUpdates = observeTransactionUpdates() storefrontChanges = observeStorefrontChanges() } @@ -109,17 +113,29 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM return nil } - 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 = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) } - let platform: SubscriptionPlatformName - + let platform: SubscriptionPlatformName = { #if os(iOS) - platform = .ios + .ios #else - platform = .macos + .macos #endif - return SubscriptionOptions(platform: platform.rawValue, + }() + + 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) } + } + + return SubscriptionOptions(platform: platform, options: options, features: features) } @@ -129,11 +145,36 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM Logger.subscription.info("[StorePurchaseManager] updateAvailableProducts") do { - let availableProducts = try await Product.products(for: productIdentifiers) - Logger.subscription.info("[StorePurchaseManager] updateAvailableProducts fetched \(availableProducts.count) products") + let storefrontCountryCode: String? + let storefrontRegion: SubscriptionRegion + + if let featureFlagger = subscriptionFeatureFlagger, featureFlagger.isFeatureOn(.isLaunchedROW) || featureFlagger.isFeatureOn(.isLaunchedROWOverride) { + if let subscriptionFeatureFlagger, subscriptionFeatureFlagger.isFeatureOn(.usePrivacyProUSARegionOverride) { + storefrontCountryCode = "USA" + } else if let subscriptionFeatureFlagger, subscriptionFeatureFlagger.isFeatureOn(.usePrivacyProROWRegionOverride) { + storefrontCountryCode = "POL" + } else { + storefrontCountryCode = await Storefront.current?.countryCode + } + + storefrontRegion = SubscriptionRegion.matchingRegion(for: storefrontCountryCode ?? "USA") ?? .usa // Fallback to USA + } else { + storefrontCountryCode = "USA" + storefrontRegion = .usa + } + + self.currentStorefrontRegion = storefrontRegion + let applicableProductIdentifiers = storeSubscriptionConfiguration.subscriptionIdentifiers(for: storefrontRegion) + let availableProducts = try await Product.products(for: applicableProductIdentifiers) + Logger.subscription.info("[StorePurchaseManager] updateAvailableProducts fetched \(availableProducts.count) products for \(storefrontCountryCode ?? "", privacy: .public)") if self.availableProducts != availableProducts { self.availableProducts = availableProducts + + // Update cached subscription features mapping + for id in availableProducts.compactMap({ $0.id }) { + _ = await subscriptionFeatureMappingCache.subscriptionFeatures(for: id) + } } } catch { Logger.subscription.error("[StorePurchaseManager] Error: \(String(reflecting: error), privacy: .public)") @@ -295,3 +336,36 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM } } } + +public extension UserDefaults { + + enum Constants { + static let storefrontRegionOverrideKey = "Subscription.debug.storefrontRegionOverride" + static let usaValue = "usa" + static let rowValue = "row" + } + + dynamic var storefrontRegionOverride: SubscriptionRegion? { + get { + switch string(forKey: Constants.storefrontRegionOverrideKey) { + case "usa": + return .usa + case "row": + return .restOfWorld + default: + return nil + } + } + + set { + switch newValue { + case .usa: + set(Constants.usaValue, forKey: Constants.storefrontRegionOverrideKey) + case .restOfWorld: + set(Constants.rowValue, forKey: Constants.storefrontRegionOverrideKey) + default: + removeObject(forKey: Constants.storefrontRegionOverrideKey) + } + } + } +} diff --git a/Sources/Subscription/Managers/SubscriptionManager.swift b/Sources/Subscription/Managers/SubscriptionManager.swift index ef8abfe85..cac861106 100644 --- a/Sources/Subscription/Managers/SubscriptionManager.swift +++ b/Sources/Subscription/Managers/SubscriptionManager.swift @@ -24,6 +24,7 @@ public protocol SubscriptionManager { var accountManager: AccountManager { get } var subscriptionEndpointService: SubscriptionEndpointService { get } var authEndpointService: AuthEndpointService { get } + var subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache { get } // Environment static func loadEnvironmentFrom(userDefaults: UserDefaults) -> SubscriptionEnvironment? @@ -35,6 +36,7 @@ public protocol SubscriptionManager { func loadInitialData() func refreshCachedSubscriptionAndEntitlements(completion: @escaping (_ isSubscriptionActive: Bool) -> Void) func url(for type: SubscriptionURL) -> URL + func currentSubscriptionFeatures() async -> [Entitlement.ProductName] } /// Single entry point for everything related to Subscription. This manager is disposable, every time something related to the environment changes this need to be recreated. @@ -43,19 +45,26 @@ public final class DefaultSubscriptionManager: SubscriptionManager { public let accountManager: AccountManager public let subscriptionEndpointService: SubscriptionEndpointService public let authEndpointService: AuthEndpointService + public let subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache public let currentEnvironment: SubscriptionEnvironment - public private(set) var canPurchase: Bool = false + + private let subscriptionFeatureFlagger: FeatureFlaggerMapping public init(storePurchaseManager: StorePurchaseManager? = nil, accountManager: AccountManager, subscriptionEndpointService: SubscriptionEndpointService, authEndpointService: AuthEndpointService, - subscriptionEnvironment: SubscriptionEnvironment) { + subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache, + subscriptionEnvironment: SubscriptionEnvironment, + subscriptionFeatureFlagger: FeatureFlaggerMapping) { self._storePurchaseManager = storePurchaseManager self.accountManager = accountManager self.subscriptionEndpointService = subscriptionEndpointService self.authEndpointService = authEndpointService + self.subscriptionFeatureMappingCache = subscriptionFeatureMappingCache self.currentEnvironment = subscriptionEnvironment + self.subscriptionFeatureFlagger = subscriptionFeatureFlagger + switch currentEnvironment.purchasePlatform { case .appStore: if #available(macOS 12.0, iOS 15.0, *) { @@ -68,6 +77,21 @@ public final class DefaultSubscriptionManager: SubscriptionManager { } } + public var canPurchase: Bool { + guard let storePurchaseManager = _storePurchaseManager else { return false } + + switch storePurchaseManager.currentStorefrontRegion { + case .usa: + return storePurchaseManager.areProductsAvailable + case .restOfWorld: + if subscriptionFeatureFlagger.isFeatureOn(.isLaunchedROW) || subscriptionFeatureFlagger.isFeatureOn(.isLaunchedROWOverride) { + return storePurchaseManager.areProductsAvailable + } else { + return false + } + } + } + @available(macOS 12.0, iOS 15.0, *) public func storePurchaseManager() -> StorePurchaseManager { return _storePurchaseManager! @@ -98,7 +122,6 @@ public final class DefaultSubscriptionManager: SubscriptionManager { @available(macOS 12.0, iOS 15.0, *) private func setupForAppStore() { Task { await storePurchaseManager().updateAvailableProducts() - canPurchase = storePurchaseManager().areProductsAvailable } } @@ -147,4 +170,21 @@ public final class DefaultSubscriptionManager: SubscriptionManager { public func url(for type: SubscriptionURL) -> URL { type.subscriptionURL(environment: currentEnvironment.serviceEnvironment) } + + // MARK: - Current subscription's features + + public func currentSubscriptionFeatures() async -> [Entitlement.ProductName] { + guard let token = accountManager.accessToken else { return [] } + + if subscriptionFeatureFlagger.isFeatureOn(.isLaunchedROW) || subscriptionFeatureFlagger.isFeatureOn(.isLaunchedROWOverride) { + switch await subscriptionEndpointService.getSubscription(accessToken: token, cachePolicy: .returnCacheDataElseLoad) { + case .success(let subscription): + return await subscriptionFeatureMappingCache.subscriptionFeatures(for: subscription.productId) + case .failure: + return [] + } + } else { + return [.networkProtection, .dataBrokerProtection, .identityTheftRestoration] + } + } } diff --git a/Sources/Subscription/NSNotificationName+Subscription.swift b/Sources/Subscription/NSNotificationName+Subscription.swift index 1079c6231..b674aaff7 100644 --- a/Sources/Subscription/NSNotificationName+Subscription.swift +++ b/Sources/Subscription/NSNotificationName+Subscription.swift @@ -20,10 +20,6 @@ import Foundation public extension NSNotification.Name { - static let openPrivateBrowsing = Notification.Name("com.duckduckgo.subscription.open.private-browsing") - static let openPrivateSearch = Notification.Name("com.duckduckgo.subscription.open.private-search") - static let openEmailProtection = Notification.Name("com.duckduckgo.subscription.open.email-protection") - static let openAppTrackingProtection = Notification.Name("com.duckduckgo.subscription.open.app-tracking-protection") static let openVPN = Notification.Name("com.duckduckgo.subscription.open.vpn") static let openPersonalInformationRemoval = Notification.Name("com.duckduckgo.subscription.open.personal-information-removal") static let openIdentityTheftRestoration = Notification.Name("com.duckduckgo.subscription.open.identity-theft-restoration") diff --git a/Sources/Subscription/StoreSubscriptionConfiguration.swift b/Sources/Subscription/StoreSubscriptionConfiguration.swift new file mode 100644 index 000000000..8dacf7db4 --- /dev/null +++ b/Sources/Subscription/StoreSubscriptionConfiguration.swift @@ -0,0 +1,138 @@ +// +// StoreSubscriptionConfiguration.swift +// +// Copyright © 2023 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 Combine + +protocol StoreSubscriptionConfiguration { + var allSubscriptionIdentifiers: [String] { get } + func subscriptionIdentifiers(for country: String) -> [String] + func subscriptionIdentifiers(for region: SubscriptionRegion) -> [String] +} + +final class DefaultStoreSubscriptionConfiguration: StoreSubscriptionConfiguration { + + private let subscriptions: [StoreSubscriptionDefinition] + + convenience init() { + self.init(subscriptionDefinitions: [ + // Production shared for iOS and macOS + .init(name: "DuckDuckGo Private Browser", + appIdentifier: "com.duckduckgo.mobile.ios", + environment: .production, + identifiersByRegion: [.usa: ["ddg.privacy.pro.monthly.renews.us", + "ddg.privacy.pro.yearly.renews.us"], + .restOfWorld: ["ddg.privacy.pro.monthly.renews.row", + "ddg.privacy.pro.yearly.renews.row"]]), + // iOS debug Alpha build + .init(name: "DuckDuckGo Alpha", + appIdentifier: "com.duckduckgo.mobile.ios.alpha", + environment: .staging, + identifiersByRegion: [.usa: ["ios.subscription.1month", + "ios.subscription.1year"], + .restOfWorld: ["ios.subscription.1month.row", + "ios.subscription.1year.row"]]), + // macOS debug build + .init(name: "IAP debug - DDG for macOS", + appIdentifier: "com.duckduckgo.macos.browser.debug", + environment: .staging, + identifiersByRegion: [.usa: ["subscription.1month", + "subscription.1year"], + .restOfWorld: ["subscription.1month.row", + "subscription.1year.row"]]), + // macOS review build + .init(name: "IAP review - DDG for macOS", + appIdentifier: "com.duckduckgo.macos.browser.review", + environment: .staging, + identifiersByRegion: [.usa: ["review.subscription.1month", + "review.subscription.1year"], + .restOfWorld: ["review.subscription.1month.row", + "review.subscription.1year.row"]]), + + // macOS TestFlight build + .init(name: "DuckDuckGo Sandbox Review", + appIdentifier: "com.duckduckgo.mobile.ios.review", + environment: .staging, + identifiersByRegion: [.usa: ["tf.sandbox.subscription.1month", + "tf.sandbox.subscription.1year"], + .restOfWorld: ["tf.sandbox.subscription.1month.row", + "tf.sandbox.subscription.1year.row"]]) + ]) + } + + init(subscriptionDefinitions: [StoreSubscriptionDefinition]) { + self.subscriptions = subscriptionDefinitions + } + + var allSubscriptionIdentifiers: [String] { + subscriptions.reduce([], { $0 + $1.allIdentifiers() }) + } + + func subscriptionIdentifiers(for country: String) -> [String] { + subscriptions.reduce([], { $0 + $1.identifiers(for: country) }) + } + + func subscriptionIdentifiers(for region: SubscriptionRegion) -> [String] { + subscriptions.reduce([], { $0 + $1.identifiers(for: region) }) + } +} + +struct StoreSubscriptionDefinition { + var name: String + var appIdentifier: String + var environment: SubscriptionEnvironment.ServiceEnvironment + var identifiersByRegion: [SubscriptionRegion: [String]] + + func allIdentifiers() -> [String] { + identifiersByRegion.values.flatMap { $0 } + } + + func identifiers(for country: String) -> [String] { + identifiersByRegion.filter { region, _ in region.contains(country) }.flatMap { _, identifiers in identifiers } + } + + func identifiers(for region: SubscriptionRegion) -> [String] { + identifiersByRegion[region] ?? [] + } +} + +public enum SubscriptionRegion: CaseIterable { + case usa + case restOfWorld + + /// Country codes as used by StoreKit, in the ISO 3166-1 Alpha-3 country code representation + /// For .restOfWorld definiton see https://app.asana.com/0/1208524871249522/1208571752166956/f + var countryCodes: Set { + switch self { + case .usa: + return Set(["USA"]) + case .restOfWorld: + return Set(["CAN", "GBR", "AUT", "DEU", "NLD", "POL", "SWE", + "BEL", "BGR", "HRV ", "CYP", "CZE", "DNK", "EST", "FIN", "FRA", "GRC", "HUN", "IRL", "ITA", "LVA", "LTU", "LUX", "MLT", "PRT", + "ROU", "SVK", "SVN", "ESP"]) + } + } + + func contains(_ country: String) -> Bool { + countryCodes.contains(country.uppercased()) + } + + static func matchingRegion(for countryCode: String) -> Self? { + Self.allCases.first { $0.countryCodes.contains(countryCode) } + } +} diff --git a/Sources/Subscription/SubscriptionFeatureMappingCache.swift b/Sources/Subscription/SubscriptionFeatureMappingCache.swift new file mode 100644 index 000000000..414fec1d7 --- /dev/null +++ b/Sources/Subscription/SubscriptionFeatureMappingCache.swift @@ -0,0 +1,136 @@ +// +// SubscriptionFeatureMappingCache.swift +// +// Copyright © 2023 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 os.log + +public protocol SubscriptionFeatureMappingCache { + func subscriptionFeatures(for subscriptionIdentifier: String) async -> [Entitlement.ProductName] +} + +public final class DefaultSubscriptionFeatureMappingCache: SubscriptionFeatureMappingCache { + + private let subscriptionEndpointService: SubscriptionEndpointService + private let userDefaults: UserDefaults + + private var subscriptionFeatureMapping: SubscriptionFeatureMapping? + + public init(subscriptionEndpointService: SubscriptionEndpointService, userDefaults: UserDefaults) { + self.subscriptionEndpointService = subscriptionEndpointService + self.userDefaults = userDefaults + } + + public func subscriptionFeatures(for subscriptionIdentifier: String) async -> [Entitlement.ProductName] { + Logger.subscription.debug("[SubscriptionFeatureMappingCache] \(#function) \(subscriptionIdentifier)") + let features: [Entitlement.ProductName] + + if let subscriptionFeatures = currentSubscriptionFeatureMapping[subscriptionIdentifier] { + Logger.subscription.debug("[SubscriptionFeatureMappingCache] - got cached features") + features = subscriptionFeatures + } else if let subscriptionFeatures = await fetchRemoteFeatures(for: subscriptionIdentifier) { + Logger.subscription.debug("[SubscriptionFeatureMappingCache] - fetching features from BE API") + features = subscriptionFeatures + updateCachedFeatureMapping(with: subscriptionFeatures, for: subscriptionIdentifier) + } else { + Logger.subscription.debug("[SubscriptionFeatureMappingCache] - Error: using fallback") + features = fallbackFeatures + } + + return features + } + + // MARK: - Current feature mapping + + private var currentSubscriptionFeatureMapping: SubscriptionFeatureMapping { + Logger.subscription.debug("[SubscriptionFeatureMappingCache] - \(#function)") + let featureMapping: SubscriptionFeatureMapping + + if let cachedFeatureMapping { + Logger.subscription.debug("[SubscriptionFeatureMappingCache] -- got cachedFeatureMapping") + featureMapping = cachedFeatureMapping + } else if let storedFeatureMapping { + Logger.subscription.debug("[SubscriptionFeatureMappingCache] -- have to fetchStoredFeatureMapping") + featureMapping = storedFeatureMapping + updateCachedFeatureMapping(to: featureMapping) + } else { + Logger.subscription.debug("[SubscriptionFeatureMappingCache] -- so creating a new one!") + featureMapping = SubscriptionFeatureMapping() + updateCachedFeatureMapping(to: featureMapping) + } + + return featureMapping + } + + // MARK: - Cached subscription feature mapping + + private var cachedFeatureMapping: SubscriptionFeatureMapping? + + private func updateCachedFeatureMapping(to featureMapping: SubscriptionFeatureMapping) { + cachedFeatureMapping = featureMapping + } + + private func updateCachedFeatureMapping(with features: [Entitlement.ProductName], for subscriptionIdentifier: String) { + var updatedFeatureMapping = cachedFeatureMapping ?? SubscriptionFeatureMapping() + updatedFeatureMapping[subscriptionIdentifier] = features + + self.cachedFeatureMapping = updatedFeatureMapping + self.storedFeatureMapping = updatedFeatureMapping + } + + // MARK: - Stored subscription feature mapping + + static private let subscriptionFeatureMappingKey = "com.duckduckgo.subscription.featuremapping" + + dynamic var storedFeatureMapping: SubscriptionFeatureMapping? { + get { + guard let data = userDefaults.data(forKey: Self.subscriptionFeatureMappingKey) else { return nil } + do { + return try JSONDecoder().decode(SubscriptionFeatureMapping?.self, from: data) + } catch { + assertionFailure("Errored while decoding feature mapping") + return nil + } + } + + set { + do { + let data = try JSONEncoder().encode(newValue) + userDefaults.set(data, forKey: Self.subscriptionFeatureMappingKey) + } catch { + assertionFailure("Errored while encoding feature mapping") + } + } + } + + // MARK: - Remote subscription feature mapping + + private func fetchRemoteFeatures(for subscriptionIdentifier: String) async -> [Entitlement.ProductName]? { + if case let .success(response) = await subscriptionEndpointService.getSubscriptionFeatures(for: subscriptionIdentifier) { + Logger.subscription.debug("[SubscriptionFeatureMappingCache] -- Fetched features for `\(subscriptionIdentifier)`: \(response.features)") + return response.features + } + + return nil + } + + // MARK: - Fallback subscription feature mapping + + private let fallbackFeatures: [Entitlement.ProductName] = [.networkProtection, .dataBrokerProtection, .identityTheftRestoration] +} + +typealias SubscriptionFeatureMapping = [String: [Entitlement.ProductName]] diff --git a/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift b/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift index 132685772..b17d585d0 100644 --- a/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift +++ b/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift @@ -22,6 +22,7 @@ import Subscription public final class SubscriptionEndpointServiceMock: SubscriptionEndpointService { public var getSubscriptionResult: Result? public var getProductsResult: Result<[GetProductsItem], APIServiceError>? + public var getSubscriptionFeaturesResult: Result? public var getCustomerPortalURLResult: Result? public var confirmPurchaseResult: Result? @@ -55,6 +56,10 @@ public final class SubscriptionEndpointServiceMock: SubscriptionEndpointService getProductsResult! } + public func getSubscriptionFeatures(for subscriptionID: String) async -> Result { + getSubscriptionFeaturesResult! + } + public func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result { getCustomerPortalURLResult! } diff --git a/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift b/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift index 6f654ba72..e326fd720 100644 --- a/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift +++ b/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift @@ -23,6 +23,8 @@ 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 syncAppleIDAccountResultError: Error? diff --git a/Sources/SubscriptionTestingUtilities/Managers/SubscriptionManagerMock.swift b/Sources/SubscriptionTestingUtilities/Managers/SubscriptionManagerMock.swift index 46fdc77e0..3217eedbb 100644 --- a/Sources/SubscriptionTestingUtilities/Managers/SubscriptionManagerMock.swift +++ b/Sources/SubscriptionTestingUtilities/Managers/SubscriptionManagerMock.swift @@ -23,6 +23,7 @@ public final class SubscriptionManagerMock: SubscriptionManager { public var accountManager: AccountManager public var subscriptionEndpointService: SubscriptionEndpointService public var authEndpointService: AuthEndpointService + public var subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache public static var storedEnvironment: SubscriptionEnvironment? public static func loadEnvironmentFrom(userDefaults: UserDefaults) -> SubscriptionEnvironment? { @@ -52,18 +53,24 @@ public final class SubscriptionManagerMock: SubscriptionManager { type.subscriptionURL(environment: currentEnvironment.serviceEnvironment) } + public func currentSubscriptionFeatures() async -> [Entitlement.ProductName] { + return [] + } + public init(accountManager: AccountManager, subscriptionEndpointService: SubscriptionEndpointService, authEndpointService: AuthEndpointService, storePurchaseManager: StorePurchaseManager, currentEnvironment: SubscriptionEnvironment, - canPurchase: Bool) { + canPurchase: Bool, + subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache) { self.accountManager = accountManager self.subscriptionEndpointService = subscriptionEndpointService self.authEndpointService = authEndpointService self.internalStorePurchaseManager = storePurchaseManager self.currentEnvironment = currentEnvironment self.canPurchase = canPurchase + self.subscriptionFeatureMappingCache = subscriptionFeatureMappingCache } // MARK: - diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionCookie/SubscriptionCookieManagerMock.swift b/Sources/SubscriptionTestingUtilities/SubscriptionCookie/SubscriptionCookieManagerMock.swift index a9166689a..b2a5b8133 100644 --- a/Sources/SubscriptionTestingUtilities/SubscriptionCookie/SubscriptionCookieManagerMock.swift +++ b/Sources/SubscriptionTestingUtilities/SubscriptionCookie/SubscriptionCookieManagerMock.swift @@ -29,13 +29,15 @@ public final class SubscriptionCookieManagerMock: SubscriptionCookieManaging { let subscriptionService = DefaultSubscriptionEndpointService(currentServiceEnvironment: .production) let authService = DefaultAuthEndpointService(currentServiceEnvironment: .production) let storePurchaseManager = StorePurchaseManagerMock() + let subscriptionFeatureMappingCache = SubscriptionFeatureMappingCacheMock() let subscriptionManager = SubscriptionManagerMock(accountManager: accountManager, subscriptionEndpointService: subscriptionService, authEndpointService: authService, storePurchaseManager: storePurchaseManager, currentEnvironment: SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .appStore), - canPurchase: true) + canPurchase: true, + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache) self.init(subscriptionManager: subscriptionManager, currentCookieStore: { return nil }, diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift b/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift new file mode 100644 index 000000000..c4612a9bd --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift @@ -0,0 +1,31 @@ +// +// SubscriptionFeatureMappingCacheMock.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 Subscription + +public final class SubscriptionFeatureMappingCacheMock: SubscriptionFeatureMappingCache { + + public var mapping: [String: [Entitlement.ProductName]] = [:] + + public init() { } + + public func subscriptionFeatures(for subscriptionIdentifier: String) async -> [Entitlement.ProductName] { + return mapping[subscriptionIdentifier] ?? [] + } +} diff --git a/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift b/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift index 5dfa14980..3fb695733 100644 --- a/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift +++ b/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift @@ -23,7 +23,7 @@ import SubscriptionTestingUtilities final class SubscriptionOptionsTests: XCTestCase { func testEncoding() throws { - let subscriptionOptions = SubscriptionOptions(platform: "macos", + let subscriptionOptions = SubscriptionOptions(platform: .macos, options: [ SubscriptionOption(id: "1", cost: SubscriptionOptionCost(displayPrice: "9 USD", recurrence: "monthly")), @@ -31,9 +31,9 @@ final class SubscriptionOptionsTests: XCTestCase { cost: SubscriptionOptionCost(displayPrice: "99 USD", recurrence: "yearly")) ], features: [ - SubscriptionFeature(name: "vpn"), - SubscriptionFeature(name: "personal-information-removal"), - SubscriptionFeature(name: "identity-theft-restoration") + SubscriptionFeature(name: .networkProtection), + SubscriptionFeature(name: .dataBrokerProtection), + SubscriptionFeature(name: .identityTheftRestoration) ]) let jsonEncoder = JSONEncoder() @@ -45,13 +45,13 @@ final class SubscriptionOptionsTests: XCTestCase { { "features" : [ { - "name" : "vpn" + "name" : "Network Protection" }, { - "name" : "personal-information-removal" + "name" : "Data Broker Protection" }, { - "name" : "identity-theft-restoration" + "name" : "Identity Theft Restoration" } ], "options" : [ @@ -87,12 +87,12 @@ final class SubscriptionOptionsTests: XCTestCase { } func testSubscriptionFeatureEncoding() throws { - let subscriptionFeature = SubscriptionFeature(name: "identity-theft-restoration") + let subscriptionFeature = SubscriptionFeature(name: .identityTheftRestoration) let data = try? JSONEncoder().encode(subscriptionFeature) let subscriptionFeatureString = String(data: data!, encoding: .utf8)! - XCTAssertEqual(subscriptionFeatureString, "{\"name\":\"identity-theft-restoration\"}") + XCTAssertEqual(subscriptionFeatureString, "{\"name\":\"Identity Theft Restoration\"}") } func testEmptySubscriptionOptions() throws { @@ -105,8 +105,8 @@ final class SubscriptionOptionsTests: XCTestCase { platform = .macos #endif - XCTAssertEqual(empty.platform, platform.rawValue) + XCTAssertEqual(empty.platform, platform) XCTAssertTrue(empty.options.isEmpty) - XCTAssertEqual(empty.features.count, SubscriptionFeatureName.allCases.count) + XCTAssertEqual(empty.features.count, 3) } } diff --git a/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift b/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift index 1752e3d15..e397805db 100644 --- a/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift +++ b/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift @@ -67,12 +67,14 @@ final class StripePurchaseFlowTests: XCTestCase { // Then switch result { case .success(let success): - XCTAssertEqual(success.platform, SubscriptionPlatformName.stripe.rawValue) + XCTAssertEqual(success.platform, SubscriptionPlatformName.stripe) XCTAssertEqual(success.options.count, SubscriptionMockFactory.productsItems.count) - XCTAssertEqual(success.features.count, SubscriptionFeatureName.allCases.count) + XCTAssertEqual(success.features.count, 3) + let allFeatures = [Entitlement.ProductName.networkProtection, Entitlement.ProductName.dataBrokerProtection, Entitlement.ProductName.identityTheftRestoration] let allNames = success.features.compactMap({ feature in feature.name}) - for name in SubscriptionFeatureName.allCases { - XCTAssertTrue(allNames.contains(name.rawValue)) + + for feature in allFeatures { + XCTAssertTrue(allNames.contains(feature)) } case .failure(let error): XCTFail("Unexpected failure: \(error)") diff --git a/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift b/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift index 9054194da..26ce6d89c 100644 --- a/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift +++ b/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift @@ -34,7 +34,9 @@ final class SubscriptionManagerTests: XCTestCase { var accountManager: AccountManagerMock! var subscriptionService: SubscriptionEndpointServiceMock! var authService: AuthEndpointServiceMock! + var subscriptionFeatureMappingCache: SubscriptionFeatureMappingCacheMock! var subscriptionEnvironment: SubscriptionEnvironment! + var subscriptionFeatureFlagger: FeatureFlaggerMapping! var subscriptionManager: SubscriptionManager! @@ -43,14 +45,18 @@ final class SubscriptionManagerTests: XCTestCase { accountManager = AccountManagerMock() subscriptionService = SubscriptionEndpointServiceMock() authService = AuthEndpointServiceMock() + subscriptionFeatureMappingCache = SubscriptionFeatureMappingCacheMock() subscriptionEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .appStore) + subscriptionFeatureFlagger = FeatureFlaggerMapping(mapping: { $0.defaultState }) subscriptionManager = DefaultSubscriptionManager(storePurchaseManager: storePurchaseManager, accountManager: accountManager, subscriptionEndpointService: subscriptionService, authEndpointService: authService, - subscriptionEnvironment: subscriptionEnvironment) + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache, + subscriptionEnvironment: subscriptionEnvironment, + subscriptionFeatureFlagger: subscriptionFeatureFlagger) } @@ -202,7 +208,9 @@ final class SubscriptionManagerTests: XCTestCase { accountManager: accountManager, subscriptionEndpointService: subscriptionService, authEndpointService: authService, - subscriptionEnvironment: productionEnvironment) + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache, + subscriptionEnvironment: productionEnvironment, + subscriptionFeatureFlagger: subscriptionFeatureFlagger) // When let productionPurchaseURL = productionSubscriptionManager.url(for: .purchase) @@ -219,7 +227,9 @@ final class SubscriptionManagerTests: XCTestCase { accountManager: accountManager, subscriptionEndpointService: subscriptionService, authEndpointService: authService, - subscriptionEnvironment: stagingEnvironment) + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache, + subscriptionEnvironment: stagingEnvironment, + subscriptionFeatureFlagger: subscriptionFeatureFlagger) // When let stagingPurchaseURL = stagingSubscriptionManager.url(for: .purchase) diff --git a/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift b/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift index 07fb12bd4..2a6a9d3d8 100644 --- a/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift +++ b/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift @@ -33,6 +33,7 @@ final class SubscriptionCookieManagerTests: XCTestCase { var authService: AuthEndpointServiceMock! var storePurchaseManager: StorePurchaseManagerMock! var subscriptionEnvironment: SubscriptionEnvironment! + var subscriptionFeatureMappingCache: SubscriptionFeatureMappingCacheMock! var subscriptionManager: SubscriptionManagerMock! var cookieStore: HTTPCookieStore! @@ -45,13 +46,15 @@ final class SubscriptionCookieManagerTests: XCTestCase { storePurchaseManager = StorePurchaseManagerMock() subscriptionEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .appStore) + subscriptionFeatureMappingCache = SubscriptionFeatureMappingCacheMock() subscriptionManager = SubscriptionManagerMock(accountManager: accountManager, subscriptionEndpointService: subscriptionService, authEndpointService: authService, storePurchaseManager: storePurchaseManager, currentEnvironment: subscriptionEnvironment, - canPurchase: true) + canPurchase: true, + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache) cookieStore = MockHTTPCookieStore() subscriptionCookieManager = SubscriptionCookieManager(subscriptionManager: subscriptionManager,