diff --git a/Package.swift b/Package.swift index 5f19e6056..162af959c 100644 --- a/Package.swift +++ b/Package.swift @@ -31,6 +31,7 @@ let package = Package( .library(name: "NetworkProtection", targets: ["NetworkProtection"]), .library(name: "NetworkProtectionTestUtils", targets: ["NetworkProtectionTestUtils"]), .library(name: "SecureStorage", targets: ["SecureStorage"]), + .library(name: "Subscription", targets: ["Subscription"]), .plugin(name: "SwiftLintPlugin", targets: ["SwiftLintPlugin"]), ], dependencies: [ @@ -303,6 +304,16 @@ let package = Package( ], plugins: [.plugin(name: "SwiftLintPlugin")] ), + .target( + name: "Subscription", + dependencies: [ + "BrowserServicesKit", + ], + swiftSettings: [ + .define("DEBUG", .when(configuration: .debug)) + ], + plugins: [.plugin(name: "SwiftLintPlugin")] + ), // MARK: - Test Targets diff --git a/Sources/Subscription/Subscription/AccountManager.swift b/Sources/Subscription/Subscription/AccountManager.swift new file mode 100644 index 000000000..e23470838 --- /dev/null +++ b/Sources/Subscription/Subscription/AccountManager.swift @@ -0,0 +1,250 @@ +// +// AccountManager.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 Common + +public extension Notification.Name { + static let accountDidSignIn = Notification.Name("com.duckduckgo.subscription.AccountDidSignIn") + static let accountDidSignOut = Notification.Name("com.duckduckgo.subscription.AccountDidSignOut") +} + +public protocol AccountManagerKeychainAccessDelegate: AnyObject { + func accountManagerKeychainAccessFailed(accessType: AccountKeychainAccessType, error: AccountKeychainAccessError) +} + +public protocol AccountManaging { + + var accessToken: String? { get } + +} + +public class AccountManager: AccountManaging { + + private let storage: AccountStorage + public weak var delegate: AccountManagerKeychainAccessDelegate? + + public var isUserAuthenticated: Bool { + return accessToken != nil + } + + public init(storage: AccountStorage = AccountKeychainStorage()) { + self.storage = storage + } + + public var authToken: String? { + do { + return try storage.getAuthToken() + } catch { + if let error = error as? AccountKeychainAccessError { + delegate?.accountManagerKeychainAccessFailed(accessType: .getAuthToken, error: error) + } else { + assertionFailure("Expected AccountKeychainAccessError") + } + + return nil + } + } + + public var accessToken: String? { + do { + return try storage.getAccessToken() + } catch { + if let error = error as? AccountKeychainAccessError { + delegate?.accountManagerKeychainAccessFailed(accessType: .getAccessToken, error: error) + } else { + assertionFailure("Expected AccountKeychainAccessError") + } + + return nil + } + } + + public var email: String? { + do { + return try storage.getEmail() + } catch { + if let error = error as? AccountKeychainAccessError { + delegate?.accountManagerKeychainAccessFailed(accessType: .getEmail, error: error) + } else { + assertionFailure("Expected AccountKeychainAccessError") + } + + return nil + } + } + + public var externalID: String? { + do { + return try storage.getExternalID() + } catch { + if let error = error as? AccountKeychainAccessError { + delegate?.accountManagerKeychainAccessFailed(accessType: .getExternalID, error: error) + } else { + assertionFailure("Expected AccountKeychainAccessError") + } + + return nil + } + } + + public func storeAuthToken(token: String) { + os_log(.info, log: .subscription, "[AccountManager] storeAuthToken") + + do { + try storage.store(authToken: token) + } catch { + if let error = error as? AccountKeychainAccessError { + delegate?.accountManagerKeychainAccessFailed(accessType: .storeAuthToken, error: error) + } else { + assertionFailure("Expected AccountKeychainAccessError") + } + } + } + + public func storeAccount(token: String, email: String?, externalID: String?) { + os_log(.info, log: .subscription, "[AccountManager] storeAccount") + + do { + try storage.store(accessToken: token) + } catch { + if let error = error as? AccountKeychainAccessError { + delegate?.accountManagerKeychainAccessFailed(accessType: .storeAccessToken, error: error) + } else { + assertionFailure("Expected AccountKeychainAccessError") + } + } + + do { + try storage.store(email: email) + } catch { + if let error = error as? AccountKeychainAccessError { + delegate?.accountManagerKeychainAccessFailed(accessType: .storeEmail, error: error) + } else { + assertionFailure("Expected AccountKeychainAccessError") + } + } + + do { + try storage.store(externalID: externalID) + } catch { + if let error = error as? AccountKeychainAccessError { + delegate?.accountManagerKeychainAccessFailed(accessType: .storeExternalID, error: error) + } else { + assertionFailure("Expected AccountKeychainAccessError") + } + } + NotificationCenter.default.post(name: .accountDidSignIn, object: self, userInfo: nil) + } + + public func signOut() { + os_log(.info, log: .subscription, "[AccountManager] signOut") + + do { + try storage.clearAuthenticationState() + } catch { + if let error = error as? AccountKeychainAccessError { + delegate?.accountManagerKeychainAccessFailed(accessType: .clearAuthenticationData, error: error) + } else { + assertionFailure("Expected AccountKeychainAccessError") + } + } + + NotificationCenter.default.post(name: .accountDidSignOut, object: self, userInfo: nil) + } + + // MARK: - + + public func hasEntitlement(for name: String) async -> Bool { + await fetchEntitlements().contains(name) + } + + public func fetchEntitlements() async -> [String] { + guard let accessToken else { return [] } + + switch await AuthService.validateToken(accessToken: accessToken) { + case .success(let response): + let entitlements = response.account.entitlements + return entitlements.map { $0.name } + + case .failure(let error): + os_log(.error, log: .subscription, "[AccountManager] fetchEntitlements error: %{public}@", error.localizedDescription) + return [] + } + } + + public func exchangeAuthTokenToAccessToken(_ authToken: String) async -> Result { + switch await AuthService.getAccessToken(token: authToken) { + case .success(let response): + return .success(response.accessToken) + case .failure(let error): + os_log(.error, log: .subscription, "[AccountManager] exchangeAuthTokenToAccessToken error: %{public}@", error.localizedDescription) + return .failure(error) + } + } + + public typealias AccountDetails = (email: String?, externalID: String) + + public func fetchAccountDetails(with accessToken: String) async -> Result { + switch await AuthService.validateToken(accessToken: accessToken) { + case .success(let response): + return .success(AccountDetails(email: response.account.email, externalID: response.account.externalID)) + case .failure(let error): + os_log(.error, log: .subscription, "[AccountManager] fetchAccountDetails error: %{public}@", error.localizedDescription) + return .failure(error) + } + } + + public func checkSubscriptionState() async { + os_log(.info, log: .subscription, "[AccountManager] checkSubscriptionState") + + guard let token = accessToken else { return } + + if case .success(let response) = await SubscriptionService.getSubscriptionDetails(token: token) { + if !response.isSubscriptionActive { + signOut() + } + } + } + + @discardableResult + public static func checkForEntitlements(wait waitTime: Double, retry retryCount: Int) async -> Bool { + var count = 0 + var hasEntitlements = false + + repeat { + hasEntitlements = await !AccountManager().fetchEntitlements().isEmpty + + if hasEntitlements { + break + } else { + count += 1 + try? await Task.sleep(seconds: waitTime) + } + } while !hasEntitlements && count < retryCount + + return hasEntitlements + } +} + +extension Task where Success == Never, Failure == Never { + static func sleep(seconds: Double) async throws { + let duration = UInt64(seconds * 1_000_000_000) + try await Task.sleep(nanoseconds: duration) + } +} diff --git a/Sources/Subscription/Subscription/AccountStorage/AccountKeychainStorage.swift b/Sources/Subscription/Subscription/AccountStorage/AccountKeychainStorage.swift new file mode 100644 index 000000000..19596f764 --- /dev/null +++ b/Sources/Subscription/Subscription/AccountStorage/AccountKeychainStorage.swift @@ -0,0 +1,195 @@ +// +// AccountKeychainStorage.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 + +public enum AccountKeychainAccessType: String { + case getAuthToken + case storeAuthToken + case getAccessToken + case storeAccessToken + case getEmail + case storeEmail + case getExternalID + case storeExternalID + case clearAuthenticationData +} + +public enum AccountKeychainAccessError: Error, Equatable { + case failedToDecodeKeychainValueAsData + case failedToDecodeKeychainDataAsString + case keychainSaveFailure(OSStatus) + case keychainDeleteFailure(OSStatus) + case keychainLookupFailure(OSStatus) + + public var errorDescription: String { + switch self { + case .failedToDecodeKeychainValueAsData: return "failedToDecodeKeychainValueAsData" + case .failedToDecodeKeychainDataAsString: return "failedToDecodeKeychainDataAsString" + case .keychainSaveFailure: return "keychainSaveFailure" + case .keychainDeleteFailure: return "keychainDeleteFailure" + case .keychainLookupFailure: return "keychainLookupFailure" + } + } +} + +public class AccountKeychainStorage: AccountStorage { + + public init() {} + + public func getAuthToken() throws -> String? { + try Self.getString(forField: .authToken) + } + + public func store(authToken: String) throws { + try Self.set(string: authToken, forField: .authToken) + } + + public func getAccessToken() throws -> String? { + try Self.getString(forField: .accessToken) + } + + public func store(accessToken: String) throws { + try Self.set(string: accessToken, forField: .accessToken) + } + + public func getEmail() throws -> String? { + try Self.getString(forField: .email) + } + + public func getExternalID() throws -> String? { + try Self.getString(forField: .externalID) + } + + public func store(externalID: String?) throws { + if let externalID = externalID, !externalID.isEmpty { + try Self.set(string: externalID, forField: .externalID) + } else { + try Self.deleteItem(forField: .externalID) + } + } + + public func store(email: String?) throws { + if let email = email, !email.isEmpty { + try Self.set(string: email, forField: .email) + } else { + try Self.deleteItem(forField: .email) + } + } + + public func clearAuthenticationState() throws { + try Self.deleteItem(forField: .authToken) + try Self.deleteItem(forField: .accessToken) + try Self.deleteItem(forField: .email) + try Self.deleteItem(forField: .externalID) + } + +} + +private extension AccountKeychainStorage { + + /* + Uses just kSecAttrService as the primary key, since we don't want to store + multiple accounts/tokens at the same time + */ + enum AccountKeychainField: String, CaseIterable { + case authToken = "account.authToken" + case accessToken = "account.accessToken" + case email = "account.email" + case externalID = "account.external_id" + + var keyValue: String { + (Bundle.main.bundleIdentifier ?? "com.duckduckgo") + "." + rawValue + } + } + + static func getString(forField field: AccountKeychainField) throws -> String? { + guard let data = try retrieveData(forField: field) else { + return nil + } + + if let decodedString = String(data: data, encoding: String.Encoding.utf8) { + return decodedString + } else { + throw AccountKeychainAccessError.failedToDecodeKeychainDataAsString + } + } + + static func retrieveData(forField field: AccountKeychainField, useDataProtectionKeychain: Bool = true) throws -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecAttrService as String: field.keyValue, + kSecReturnData as String: true, + kSecUseDataProtectionKeychain as String: useDataProtectionKeychain + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + if status == errSecSuccess { + if let existingItem = item as? Data { + return existingItem + } else { + throw AccountKeychainAccessError.failedToDecodeKeychainValueAsData + } + } else if status == errSecItemNotFound { + return nil + } else { + throw AccountKeychainAccessError.keychainLookupFailure(status) + } + } + + static func set(string: String, forField field: AccountKeychainField) throws { + guard let stringData = string.data(using: .utf8) else { + return + } + + try deleteItem(forField: field) + try store(data: stringData, forField: field) + } + + static func store(data: Data, forField field: AccountKeychainField, useDataProtectionKeychain: Bool = true) throws { + let query = [ + kSecClass: kSecClassGenericPassword, + kSecAttrSynchronizable: false, + kSecAttrService: field.keyValue, + kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock, + kSecValueData: data, + kSecUseDataProtectionKeychain: useDataProtectionKeychain] as [String: Any] + + let status = SecItemAdd(query as CFDictionary, nil) + + if status != errSecSuccess { + throw AccountKeychainAccessError.keychainSaveFailure(status) + } + } + + static func deleteItem(forField field: AccountKeychainField, useDataProtectionKeychain: Bool = true) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: field.keyValue, + kSecUseDataProtectionKeychain as String: useDataProtectionKeychain] + + let status = SecItemDelete(query as CFDictionary) + + if status != errSecSuccess && status != errSecItemNotFound { + throw AccountKeychainAccessError.keychainDeleteFailure(status) + } + } +} diff --git a/Sources/Subscription/Subscription/AccountStorage/AccountStorage.swift b/Sources/Subscription/Subscription/AccountStorage/AccountStorage.swift new file mode 100644 index 000000000..06b5e05cb --- /dev/null +++ b/Sources/Subscription/Subscription/AccountStorage/AccountStorage.swift @@ -0,0 +1,31 @@ +// +// AccountStorage.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 + +public protocol AccountStorage: AnyObject { + func getAuthToken() throws -> String? + func store(authToken: String) throws + func getAccessToken() throws -> String? + func store(accessToken: String) throws + func getEmail() throws -> String? + func store(email: String?) throws + func getExternalID() throws -> String? + func store(externalID: String?) throws + func clearAuthenticationState() throws +} diff --git a/Sources/Subscription/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift b/Sources/Subscription/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift new file mode 100644 index 000000000..7b502d214 --- /dev/null +++ b/Sources/Subscription/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift @@ -0,0 +1,58 @@ +// +// AppStoreAccountManagementFlow.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 StoreKit +import Common + +@available(macOS 12.0, iOS 15.0, *) +public final class AppStoreAccountManagementFlow { + + public enum Error: Swift.Error { + case noPastTransaction + case authenticatingWithTransactionFailed + } + + @discardableResult + public static func refreshAuthTokenIfNeeded() async -> Result { + os_log(.info, log: .subscription, "[AppStoreAccountManagementFlow] refreshAuthTokenIfNeeded") + + var authToken = AccountManager().authToken ?? "" + + // Check if auth token if still valid + if case let .failure(validateTokenError) = await AuthService.validateToken(accessToken: authToken) { + os_log(.error, log: .subscription, "[AppStoreAccountManagementFlow] validateToken error: %{public}s", String(reflecting: validateTokenError)) + + // In case of invalid token attempt store based authentication to obtain a new one + guard let lastTransactionJWSRepresentation = await PurchaseManager.mostRecentTransaction() else { return .failure(.noPastTransaction) } + + switch await AuthService.storeLogin(signature: lastTransactionJWSRepresentation) { + case .success(let response): + if response.externalID == AccountManager().externalID { + authToken = response.authToken + AccountManager().storeAuthToken(token: authToken) + } + case .failure(let storeLoginError): + os_log(.error, log: .subscription, "[AppStoreAccountManagementFlow] storeLogin error: %{public}s", String(reflecting: storeLoginError)) + return .failure(.authenticatingWithTransactionFailed) + } + } + + return .success(authToken) + } +} diff --git a/Sources/Subscription/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift b/Sources/Subscription/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift new file mode 100644 index 000000000..ef822341b --- /dev/null +++ b/Sources/Subscription/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift @@ -0,0 +1,113 @@ +// +// AppStorePurchaseFlow.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 StoreKit +import Common + +@available(macOS 12.0, iOS 15.0, *) +public final class AppStorePurchaseFlow { + + public enum Error: Swift.Error { + case noProductsFound + case activeSubscriptionAlreadyPresent + case authenticatingWithTransactionFailed + case accountCreationFailed + case purchaseFailed + case missingEntitlements + } + + public static func subscriptionOptions() async -> Result { + os_log(.info, log: .subscription, "[AppStorePurchaseFlow] subscriptionOptions") + + let products = PurchaseManager.shared.availableProducts + + let monthly = products.first(where: { $0.id.contains("1month") }) + let yearly = products.first(where: { $0.id.contains("1year") }) + + guard let monthly, let yearly else { + os_log(.error, log: .subscription, "[AppStorePurchaseFlow] Error: noProductsFound") + return .failure(.noProductsFound) + } + + 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) } + + return .success(SubscriptionOptions(platform: SubscriptionPlatformName.macos.rawValue, + options: options, + features: features)) + } + + public static func purchaseSubscription(with subscriptionIdentifier: String, emailAccessToken: String?) async -> Result { + os_log(.info, log: .subscription, "[AppStorePurchaseFlow] purchaseSubscription") + + let accountManager = AccountManager() + let externalID: String + + // Check for past transactions most recent + switch await AppStoreRestoreFlow.restoreAccountFromPastPurchase() { + case .success: + os_log(.info, log: .subscription, "[AppStorePurchaseFlow] purchaseSubscription -> restoreAccountFromPastPurchase: activeSubscriptionAlreadyPresent") + return .failure(.activeSubscriptionAlreadyPresent) + case .failure(let error): + os_log(.info, log: .subscription, "[AppStorePurchaseFlow] purchaseSubscription -> restoreAccountFromPastPurchase: %{public}s", String(reflecting: error)) + switch error { + case .subscriptionExpired(let expiredAccountDetails): + externalID = expiredAccountDetails.externalID + accountManager.storeAuthToken(token: expiredAccountDetails.authToken) + accountManager.storeAccount(token: expiredAccountDetails.accessToken, email: expiredAccountDetails.email, externalID: expiredAccountDetails.externalID) + default: + // No history, create new account + switch await AuthService.createAccount(emailAccessToken: emailAccessToken) { + case .success(let response): + externalID = response.externalID + + if case let .success(accessToken) = await accountManager.exchangeAuthTokenToAccessToken(response.authToken), + case let .success(accountDetails) = await accountManager.fetchAccountDetails(with: accessToken) { + accountManager.storeAuthToken(token: response.authToken) + accountManager.storeAccount(token: accessToken, email: accountDetails.email, externalID: accountDetails.externalID) + } + case .failure(let error): + os_log(.error, log: .subscription, "[AppStorePurchaseFlow] createAccount error: %{public}s", String(reflecting: error)) + return .failure(.accountCreationFailed) + } + } + } + + // Make the purchase + switch await PurchaseManager.shared.purchaseSubscription(with: subscriptionIdentifier, externalID: externalID) { + case .success: + return .success(()) + case .failure(let error): + os_log(.error, log: .subscription, "[AppStorePurchaseFlow] purchaseSubscription error: %{public}s", String(reflecting: error)) + AccountManager().signOut() + return .failure(.purchaseFailed) + } + } + + @discardableResult + public static func completeSubscriptionPurchase() async -> Result { + os_log(.info, log: .subscription, "[AppStorePurchaseFlow] completeSubscriptionPurchase") + + let result = await AccountManager.checkForEntitlements(wait: 2.0, retry: 20) + + return result ? .success(PurchaseUpdate(type: "completed")) : .failure(.missingEntitlements) + } +} diff --git a/Sources/Subscription/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift b/Sources/Subscription/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift new file mode 100644 index 000000000..c0ee4df79 --- /dev/null +++ b/Sources/Subscription/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift @@ -0,0 +1,99 @@ +// +// AppStoreRestoreFlow.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 StoreKit +import Common + +@available(macOS 12.0, iOS 15.0, *) +public final class AppStoreRestoreFlow { + + public typealias RestoredAccountDetails = (authToken: String, accessToken: String, externalID: String, email: String?) + + public enum Error: Swift.Error { + case missingAccountOrTransactions + case pastTransactionAuthenticationError + case failedToObtainAccessToken + case failedToFetchAccountDetails + case failedToFetchSubscriptionDetails + case subscriptionExpired(accountDetails: RestoredAccountDetails) + } + + public static func restoreAccountFromPastPurchase() async -> Result { + os_log(.info, log: .subscription, "[AppStoreRestoreFlow] restoreAccountFromPastPurchase") + + guard let lastTransactionJWSRepresentation = await PurchaseManager.mostRecentTransaction() else { + os_log(.error, log: .subscription, "[AppStoreRestoreFlow] Error: missingAccountOrTransactions") + return .failure(.missingAccountOrTransactions) + } + + let accountManager = AccountManager() + + // Do the store login to get short-lived token + let authToken: String + + switch await AuthService.storeLogin(signature: lastTransactionJWSRepresentation) { + case .success(let response): + authToken = response.authToken + case .failure: + os_log(.error, log: .subscription, "[AppStoreRestoreFlow] Error: pastTransactionAuthenticationError") + return .failure(.pastTransactionAuthenticationError) + } + + let accessToken: String + let email: String? + let externalID: String + + switch await accountManager.exchangeAuthTokenToAccessToken(authToken) { + case .success(let exchangedAccessToken): + accessToken = exchangedAccessToken + case .failure: + os_log(.error, log: .subscription, "[AppStoreRestoreFlow] Error: failedToObtainAccessToken") + return .failure(.failedToObtainAccessToken) + } + + switch await accountManager.fetchAccountDetails(with: accessToken) { + case .success(let accountDetails): + email = accountDetails.email + externalID = accountDetails.externalID + case .failure: + os_log(.error, log: .subscription, "[AppStoreRestoreFlow] Error: failedToFetchAccountDetails") + return .failure(.failedToFetchAccountDetails) + } + + var isSubscriptionActive = false + + switch await SubscriptionService.getSubscriptionDetails(token: accessToken) { + case .success(let response): + isSubscriptionActive = response.isSubscriptionActive + case .failure: + os_log(.error, log: .subscription, "[AppStoreRestoreFlow] Error: failedToFetchSubscriptionDetails") + return .failure(.failedToFetchSubscriptionDetails) + } + + if isSubscriptionActive { + accountManager.storeAuthToken(token: authToken) + accountManager.storeAccount(token: accessToken, email: email, externalID: externalID) + return .success(()) + } else { + let details = RestoredAccountDetails(authToken: authToken, accessToken: accessToken, externalID: externalID, email: email) + os_log(.error, log: .subscription, "[AppStoreRestoreFlow] Error: subscriptionExpired") + return .failure(.subscriptionExpired(accountDetails: details)) + } + } +} diff --git a/Sources/Subscription/Subscription/Flows/PurchaseFlow.swift b/Sources/Subscription/Subscription/Flows/PurchaseFlow.swift new file mode 100644 index 000000000..f32765ff9 --- /dev/null +++ b/Sources/Subscription/Subscription/Flows/PurchaseFlow.swift @@ -0,0 +1,68 @@ +// +// PurchaseFlow.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 + +public struct SubscriptionOptions: Encodable { + let platform: String + let options: [SubscriptionOption] + let features: [SubscriptionFeature] +} + +public struct SubscriptionOption: Encodable { + let id: String + let cost: SubscriptionOptionCost +} + +struct SubscriptionOptionCost: Encodable { + let displayPrice: String + let recurrence: String +} + +public struct SubscriptionFeature: Encodable { + let name: String +} + +// MARK: - + +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 SubscriptionPlatformName: String { + case macos + case stripe +} + +// MARK: - + +public struct PurchaseUpdate: Codable { + let type: String + let token: String? + + public init(type: String, token: String? = nil) { + self.type = type + self.token = token + } +} diff --git a/Sources/Subscription/Subscription/Flows/Stripe/StripePurchaseFlow.swift b/Sources/Subscription/Subscription/Flows/Stripe/StripePurchaseFlow.swift new file mode 100644 index 000000000..9bd19a034 --- /dev/null +++ b/Sources/Subscription/Subscription/Flows/Stripe/StripePurchaseFlow.swift @@ -0,0 +1,96 @@ +// +// StripePurchaseFlow.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 StoreKit +import Common + +public final class StripePurchaseFlow { + + public enum Error: Swift.Error { + case noProductsFound + case accountCreationFailed + } + + public static func subscriptionOptions() async -> Result { + os_log(.info, log: .subscription, "[StripePurchaseFlow] subscriptionOptions") + + guard case let .success(products) = await SubscriptionService.getProducts(), !products.isEmpty else { + os_log(.error, log: .subscription, "[StripePurchaseFlow] Error: noProductsFound") + return .failure(.noProductsFound) + } + + let currency = products.first?.currency ?? "USD" + + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = Locale(identifier: "en_US@currency=\(currency)") + + let options: [SubscriptionOption] = products.map { + var displayPrice = "\($0.price) \($0.currency)" + + if let price = Float($0.price), let formattedPrice = formatter.string(from: price as NSNumber) { + displayPrice = formattedPrice + } + + let cost = SubscriptionOptionCost(displayPrice: displayPrice, recurrence: $0.billingPeriod.lowercased()) + + return SubscriptionOption(id: $0.productId, + cost: cost) + } + + let features = SubscriptionFeatureName.allCases.map { SubscriptionFeature(name: $0.rawValue) } + + return .success(SubscriptionOptions(platform: SubscriptionPlatformName.stripe.rawValue, + options: options, + features: features)) + } + + public static func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result { + os_log(.info, log: .subscription, "[StripePurchaseFlow] prepareSubscriptionPurchase") + + var authToken: String = "" + + switch await AuthService.createAccount(emailAccessToken: emailAccessToken) { + case .success(let response): + authToken = response.authToken + AccountManager().storeAuthToken(token: authToken) + case .failure: + os_log(.error, log: .subscription, "[StripePurchaseFlow] Error: accountCreationFailed") + return .failure(.accountCreationFailed) + } + + return .success(PurchaseUpdate(type: "redirect", token: authToken)) + } + + public static func completeSubscriptionPurchase() async { + os_log(.info, log: .subscription, "[StripePurchaseFlow] completeSubscriptionPurchase") + + let accountManager = AccountManager() + + if let authToken = accountManager.authToken { + if case let .success(accessToken) = await accountManager.exchangeAuthTokenToAccessToken(authToken), + case let .success(accountDetails) = await accountManager.fetchAccountDetails(with: accessToken) { + accountManager.storeAuthToken(token: authToken) + accountManager.storeAccount(token: accessToken, email: accountDetails.email, externalID: accountDetails.externalID) + } + } + + await AccountManager.checkForEntitlements(wait: 2.0, retry: 5) + } +} diff --git a/Sources/Subscription/Subscription/NSNotificationName+Subscription.swift b/Sources/Subscription/Subscription/NSNotificationName+Subscription.swift new file mode 100644 index 000000000..20fc2f7b1 --- /dev/null +++ b/Sources/Subscription/Subscription/NSNotificationName+Subscription.swift @@ -0,0 +1,30 @@ +// +// NSNotificationName+Subscription.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 + +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/Subscription/PurchaseManager.swift b/Sources/Subscription/Subscription/PurchaseManager.swift new file mode 100644 index 000000000..5b04b7f5c --- /dev/null +++ b/Sources/Subscription/Subscription/PurchaseManager.swift @@ -0,0 +1,260 @@ +// +// PurchaseManager.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 StoreKit +import Common + +@available(macOS 12.0, iOS 15.0, *) typealias Transaction = StoreKit.Transaction +@available(macOS 12.0, iOS 15.0, *) typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo +@available(macOS 12.0, iOS 15.0, *) typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState + +public enum StoreError: Error { + case failedVerification +} + +enum PurchaseManagerError: Error { + case productNotFound + case externalIDisNotAValidUUID + case purchaseFailed + case transactionCannotBeVerified + case transactionPendingAuthentication + case purchaseCancelledByUser + case unknownError +} + +@available(macOS 12.0, iOS 15.0, *) +public final class PurchaseManager: ObservableObject { + + static let productIdentifiers = ["ios.subscription.1month", "ios.subscription.1year", + "subscription.1week", "subscription.1month", "subscription.1year", + "review.subscription.1week", "review.subscription.1month", "review.subscription.1year"] + + public static let shared = PurchaseManager() + + @Published public private(set) var availableProducts: [Product] = [] + @Published public private(set) var purchasedProductIDs: [String] = [] + @Published public private(set) var purchaseQueue: [String] = [] + + @Published private(set) var subscriptionGroupStatus: RenewalState? + + private var transactionUpdates: Task? + private var storefrontChanges: Task? + + public init() { + transactionUpdates = observeTransactionUpdates() + storefrontChanges = observeStorefrontChanges() + } + + deinit { + transactionUpdates?.cancel() + storefrontChanges?.cancel() + } + + @MainActor + @discardableResult + public func syncAppleIDAccount() async -> Result { + do { + purchaseQueue.removeAll() + + os_log(.info, log: .subscription, "[PurchaseManager] Before AppStore.sync()") + + try await AppStore.sync() + + os_log(.info, log: .subscription, "[PurchaseManager] After AppStore.sync()") + + await updatePurchasedProducts() + await updateAvailableProducts() + + return .success(()) + } catch { + os_log(.error, log: .subscription, "[PurchaseManager] Error: %{public}s", String(reflecting: error)) + return .failure(error) + } + } + + @MainActor + public func updateAvailableProducts() async { + os_log(.info, log: .subscription, "[PurchaseManager] updateAvailableProducts") + + do { + let availableProducts = try await Product.products(for: Self.productIdentifiers) + os_log(.info, log: .subscription, "[PurchaseManager] updateAvailableProducts fetched %d products", availableProducts.count) + + if self.availableProducts != availableProducts { + self.availableProducts = availableProducts + } + } catch { + os_log(.error, log: .subscription, "[PurchaseManager] Error: %{public}s", String(reflecting: error)) + } + } + + @MainActor + public func updatePurchasedProducts() async { + os_log(.info, log: .subscription, "[PurchaseManager] updatePurchasedProducts") + + var purchasedSubscriptions: [String] = [] + + do { + for await result in Transaction.currentEntitlements { + let transaction = try checkVerified(result) + + guard transaction.productType == .autoRenewable else { continue } + guard transaction.revocationDate == nil else { continue } + + if let expirationDate = transaction.expirationDate, expirationDate > .now { + purchasedSubscriptions.append(transaction.productID) + } + } + } catch { + os_log(.error, log: .subscription, "[PurchaseManager] Error: %{public}s", String(reflecting: error)) + } + + os_log(.info, log: .subscription, "[PurchaseManager] updatePurchasedProducts fetched %d active subscriptions", purchasedSubscriptions.count) + + if self.purchasedProductIDs != purchasedSubscriptions { + self.purchasedProductIDs = purchasedSubscriptions + } + + subscriptionGroupStatus = try? await availableProducts.first?.subscription?.status.first?.state + } + + @MainActor + public static func mostRecentTransaction() async -> String? { + os_log(.info, log: .subscription, "[PurchaseManager] mostRecentTransaction") + + var transactions: [VerificationResult] = [] + + for await result in Transaction.all { + transactions.append(result) + } + + os_log(.info, log: .subscription, "[PurchaseManager] mostRecentTransaction fetched %d transactions", transactions.count) + + return transactions.first?.jwsRepresentation + } + + @MainActor + public static func hasActiveSubscription() async -> Bool { + os_log(.info, log: .subscription, "[PurchaseManager] hasActiveSubscription") + + var transactions: [VerificationResult] = [] + + for await result in Transaction.currentEntitlements { + transactions.append(result) + } + + os_log(.info, log: .subscription, "[PurchaseManager] hasActiveSubscription fetched %d transactions", transactions.count) + + return !transactions.isEmpty + } + + @MainActor + public func purchaseSubscription(with identifier: String, externalID: String) async -> Result { + + guard let product = availableProducts.first(where: { $0.id == identifier }) else { return .failure(PurchaseManagerError.productNotFound) } + + os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription %{public}s (%{public}s)", product.displayName, externalID) + + purchaseQueue.append(product.id) + + var options: Set = Set() + + if let token = UUID(uuidString: externalID) { + options.insert(.appAccountToken(token)) + } else { + os_log(.error, log: .subscription, "[PurchaseManager] Error: Failed to create UUID") + return .failure(PurchaseManagerError.externalIDisNotAValidUUID) + } + + let result: Product.PurchaseResult + do { + result = try await product.purchase(options: options) + } catch { + os_log(.error, log: .subscription, "[PurchaseManager] Error: %{public}s", String(reflecting: error)) + return .failure(PurchaseManagerError.purchaseFailed) + } + + os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription complete") + + purchaseQueue.removeAll() + + switch result { + case let .success(.verified(transaction)): + os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription result: success") + // Successful purchase + await transaction.finish() + await self.updatePurchasedProducts() + return .success(()) + case let .success(.unverified(_, error)): + os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription result: success /unverified/ - %{public}s", String(reflecting: error)) + // Successful purchase but transaction/receipt can't be verified + // Could be a jailbroken phone + return .failure(PurchaseManagerError.transactionCannotBeVerified) + case .pending: + os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription result: pending") + // Transaction waiting on SCA (Strong Customer Authentication) or + // approval from Ask to Buy + return .failure(PurchaseManagerError.transactionPendingAuthentication) + case .userCancelled: + os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription result: user cancelled") + return .failure(PurchaseManagerError.purchaseCancelledByUser) + @unknown default: + os_log(.info, log: .subscription, "[PurchaseManager] purchaseSubscription result: unknown") + return .failure(PurchaseManagerError.unknownError) + } + } + + private func checkVerified(_ result: VerificationResult) throws -> T { + // Check whether the JWS passes StoreKit verification. + switch result { + case .unverified: + // StoreKit parses the JWS, but it fails verification. + throw StoreError.failedVerification + case .verified(let safe): + // The result is verified. Return the unwrapped value. + return safe + } + } + + private func observeTransactionUpdates() -> Task { + + Task.detached { [unowned self] in + for await result in Transaction.updates { + os_log(.info, log: .subscription, "[PurchaseManager] observeTransactionUpdates") + + if case .verified(let transaction) = result { + await transaction.finish() + } + + await self.updatePurchasedProducts() + } + } + } + + private func observeStorefrontChanges() -> Task { + + Task.detached { [unowned self] in + for await result in Storefront.updates { + os_log(.info, log: .subscription, "[PurchaseManager] observeStorefrontChanges: %s", result.countryCode) + await updatePurchasedProducts() + await updateAvailableProducts() + } + } + } +} diff --git a/Sources/Subscription/Subscription/Services/APIService.swift b/Sources/Subscription/Subscription/Services/APIService.swift new file mode 100644 index 000000000..4d79e574b --- /dev/null +++ b/Sources/Subscription/Subscription/Services/APIService.swift @@ -0,0 +1,113 @@ +// +// APIService.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 Common + +public enum APIServiceError: Swift.Error { + case decodingError + case encodingError + case serverError(description: String) + case unknownServerError + case connectionError +} + +struct ErrorResponse: Decodable { + let error: String +} + +public protocol APIService { + static var baseURL: URL { get } + static var session: URLSession { get } + static func executeAPICall(method: String, endpoint: String, headers: [String: String]?, body: Data?) async -> Result where T: Decodable +} + +public extension APIService { + + static func executeAPICall(method: String, endpoint: String, headers: [String: String]? = nil, body: Data? = nil) async -> Result where T: Decodable { + let request = makeAPIRequest(method: method, endpoint: endpoint, headers: headers, body: body) + + do { + let (data, urlResponse) = try await session.data(for: request) + + printDebugInfo(method: method, endpoint: endpoint, data: data, response: urlResponse) + + if let httpResponse = urlResponse as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) { + if let decodedResponse = decode(T.self, from: data) { + return .success(decodedResponse) + } else { + os_log(.error, log: .subscription, "Service error: APIServiceError.decodingError") + return .failure(.decodingError) + } + } else { + if let decodedResponse = decode(ErrorResponse.self, from: data) { + let errorDescription = "[\(endpoint)] \(urlResponse.httpStatusCodeAsString ?? ""): \(decodedResponse.error)" + os_log(.error, log: .subscription, "Service error: %{public}@", errorDescription) + return .failure(.serverError(description: errorDescription)) + } else { + os_log(.error, log: .subscription, "Service error: APIServiceError.unknownServerError") + return .failure(.unknownServerError) + } + } + } catch { + os_log(.error, log: .subscription, "Service error: %{public}@", error.localizedDescription) + return .failure(.connectionError) + } + } + + private static func makeAPIRequest(method: String, endpoint: String, headers: [String: String]?, body: Data?) -> URLRequest { + let url = baseURL.appendingPathComponent(endpoint) + var request = URLRequest(url: url) + request.httpMethod = method + if let headers = headers { + request.allHTTPHeaderFields = headers + } + if let body = body { + request.httpBody = body + } + + return request + } + + private static func decode(_: T.Type, from data: Data) -> T? where T: Decodable { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .millisecondsSince1970 + + return try? decoder.decode(T.self, from: data) + } + + private static func printDebugInfo(method: String, endpoint: String, data: Data, response: URLResponse) { + let statusCode = (response as? HTTPURLResponse)!.statusCode + let stringData = String(data: data, encoding: .utf8) ?? "" + + os_log(.info, log: .subscription, "[API] %d %{public}s /%{public}s :: %{public}s", statusCode, method, endpoint, stringData) + } + + static func makeAuthorizationHeader(for token: String) -> [String: String] { + ["Authorization": "Bearer " + token] + } +} + +extension URLResponse { + + var httpStatusCodeAsString: String? { + guard let httpStatusCode = (self as? HTTPURLResponse)?.statusCode else { return nil } + return String(httpStatusCode) + } +} diff --git a/Sources/Subscription/Subscription/Services/AuthService.swift b/Sources/Subscription/Subscription/Services/AuthService.swift new file mode 100644 index 000000000..4e4755ad6 --- /dev/null +++ b/Sources/Subscription/Subscription/Services/AuthService.swift @@ -0,0 +1,111 @@ +// +// AuthService.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 Common + +public struct AuthService: APIService { + + public static let session = { + let configuration = URLSessionConfiguration.ephemeral + return URLSession(configuration: configuration) + }() + public static let baseURL = URL(string: "https://quackdev.duckduckgo.com/api/auth")! + + // MARK: - + + public static func getAccessToken(token: String) async -> Result { + await executeAPICall(method: "GET", endpoint: "access-token", headers: makeAuthorizationHeader(for: token)) + } + + public struct AccessTokenResponse: Decodable { + public let accessToken: String + } + + // MARK: - + + public static func validateToken(accessToken: String) async -> Result { + await executeAPICall(method: "GET", endpoint: "validate-token", headers: makeAuthorizationHeader(for: accessToken)) + } + + // swiftlint:disable nesting + public struct ValidateTokenResponse: Decodable { + public let account: Account + + public struct Account: Decodable { + public let email: String? + let entitlements: [Entitlement] + public let externalID: String + + enum CodingKeys: String, CodingKey { + case email, entitlements, externalID = "externalId" // no underscores due to keyDecodingStrategy = .convertFromSnakeCase + } + } + + struct Entitlement: Decodable { + let id: Int + let name: String + let product: String + } + } + // swiftlint:enable nesting + + // MARK: - + + public static func createAccount(emailAccessToken: String?) async -> Result { + var headers: [String: String]? + + if let emailAccessToken { + headers = makeAuthorizationHeader(for: emailAccessToken) + } + + return await executeAPICall(method: "POST", endpoint: "account/create", headers: headers) + } + + public struct CreateAccountResponse: Decodable { + public let authToken: String + public let externalID: String + public let status: String + + enum CodingKeys: String, CodingKey { + case authToken = "authToken", externalID = "externalId", status // no underscores due to keyDecodingStrategy = .convertFromSnakeCase + } + } + + // MARK: - + + public static func storeLogin(signature: String) async -> Result { + let bodyDict = ["signature": signature, + "store": "apple_app_store"] + + guard let bodyData = try? JSONEncoder().encode(bodyDict) else { return .failure(.encodingError) } + return await executeAPICall(method: "POST", endpoint: "store-login", body: bodyData) + } + + public struct StoreLoginResponse: Decodable { + public let authToken: String + public let email: String + public let externalID: String + public let id: Int + public let status: String + + enum CodingKeys: String, CodingKey { + case authToken = "authToken", email, externalID = "externalId", id, status // no underscores due to keyDecodingStrategy = .convertFromSnakeCase + } + } +} diff --git a/Sources/Subscription/Subscription/Services/SubscriptionService.swift b/Sources/Subscription/Subscription/Services/SubscriptionService.swift new file mode 100644 index 000000000..62c7a7ca2 --- /dev/null +++ b/Sources/Subscription/Subscription/Services/SubscriptionService.swift @@ -0,0 +1,96 @@ +// +// SubscriptionService.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 Common + +public struct SubscriptionService: APIService { + + public static let session = { + let configuration = URLSessionConfiguration.ephemeral + return URLSession(configuration: configuration) + }() + public static let baseURL = URL(string: "https://subscriptions-dev.duckduckgo.com/api")! + + // MARK: - + + public static func getSubscriptionDetails(token: String) async -> Result { + let result: Result = await executeAPICall(method: "GET", endpoint: "subscription", headers: makeAuthorizationHeader(for: token)) + + switch result { + case .success(let response): + cachedSubscriptionDetailsResponse = response + case .failure: + cachedSubscriptionDetailsResponse = nil + } + + return result + } + + public struct GetSubscriptionDetailsResponse: Decodable { + public let productId: String + public let startedAt: Date + public let expiresOrRenewsAt: Date + public let platform: Platform + public let status: String + + public var isSubscriptionActive: Bool { + status.lowercased() != "expired" && status.lowercased() != "inactive" + } + + public enum Platform: String, Codable { + case apple, google, stripe + case unknown + + public init(from decoder: Decoder) throws { + self = try Self(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown + } + } + } + + public static var cachedSubscriptionDetailsResponse: GetSubscriptionDetailsResponse? + + // MARK: - + + public static func getProducts() async -> Result { + await executeAPICall(method: "GET", endpoint: "products") + } + + public typealias GetProductsResponse = [GetProductsItem] + + public struct GetProductsItem: Decodable { + public let productId: String + public let productLabel: String + public let billingPeriod: String + public let price: String + public let currency: String + } + + // MARK: - + + public static func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result { + var headers = makeAuthorizationHeader(for: accessToken) + headers["externalAccountId"] = externalID + return await executeAPICall(method: "GET", endpoint: "checkout/portal", headers: headers) + } + + public struct GetCustomerPortalURLResponse: Decodable { + public let customerPortalUrl: String + } + +} diff --git a/Sources/Subscription/Subscription/SubscriptionPurchaseEnvironment.swift b/Sources/Subscription/Subscription/SubscriptionPurchaseEnvironment.swift new file mode 100644 index 000000000..4414fd965 --- /dev/null +++ b/Sources/Subscription/Subscription/SubscriptionPurchaseEnvironment.swift @@ -0,0 +1,65 @@ +// +// SubscriptionPurchaseEnvironment.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 Common + +public final class SubscriptionPurchaseEnvironment { + + public enum Environment: String { + case appStore, stripe + } + + public static var current: Environment = .appStore { + didSet { + os_log(.info, log: .subscription, "[SubscriptionPurchaseEnvironment] Setting to %{public}s", current.rawValue) + + canPurchase = false + + switch current { + case .appStore: + setupForAppStore() + case .stripe: + setupForStripe() + } + } + } + + public static var canPurchase: Bool = false { + didSet { + os_log(.info, log: .subscription, "[SubscriptionPurchaseEnvironment] canPurchase %{public}s", (canPurchase ? "true" : "false")) + } + } + + private static func setupForAppStore() { + if #available(macOS 12.0, iOS 15.0, *) { + Task { + await PurchaseManager.shared.updateAvailableProducts() + canPurchase = !PurchaseManager.shared.availableProducts.isEmpty + } + } + } + + private static func setupForStripe() { + Task { + if case let .success(products) = await SubscriptionService.getProducts() { + canPurchase = !products.isEmpty + } + } + } +} diff --git a/Sources/Subscription/Subscription/URL+Subscription.swift b/Sources/Subscription/Subscription/URL+Subscription.swift new file mode 100644 index 000000000..8993ca017 --- /dev/null +++ b/Sources/Subscription/Subscription/URL+Subscription.swift @@ -0,0 +1,55 @@ +// +// URL+Subscription.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 + +public extension URL { + + static var purchaseSubscription: URL { + URL(string: "https://abrown.duckduckgo.com/subscriptions/welcome")! + } + + static var subscriptionFAQ: URL { + URL(string: "https://duckduckgo.com/about")! + } + + // MARK: - Subscription Email + static var activateSubscriptionViaEmail: URL { + URL(string: "https://abrown.duckduckgo.com/subscriptions/activate")! + } + + static var addEmailToSubscription: URL { + URL(string: "https://abrown.duckduckgo.com/subscriptions/add-email")! + } + + static var manageSubscriptionEmail: URL { + URL(string: "https://abrown.duckduckgo.com/subscriptions/manage")! + } + + // MARK: - App Store app manage subscription URL + + static var manageSubscriptionsInAppStoreAppURL: URL { + URL(string: "macappstores://apps.apple.com/account/subscriptions")! + } + + // MARK: - Identity Theft Restoration + + static var identityTheftRestoration: URL { + URL(string: "https://abrown.duckduckgo.com/identity-theft-restoration")! + } +}