Skip to content

Commit

Permalink
Update Subscription Code from macOS
Browse files Browse the repository at this point in the history
  • Loading branch information
afterxleep committed Dec 13, 2023
1 parent 140bdb6 commit 1735eb6
Show file tree
Hide file tree
Showing 23 changed files with 658 additions and 284 deletions.
195 changes: 108 additions & 87 deletions DuckDuckGo.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

70 changes: 0 additions & 70 deletions DuckDuckGo/PrivacyPro/PurchaseFlows/AppStoreRestoreFlow.swift

This file was deleted.

35 changes: 13 additions & 22 deletions DuckDuckGo/PrivacyPro/Subscription/AccountManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,44 +176,35 @@ public class AccountManager {
}
}

@discardableResult
public func exchangeAndStoreTokens(with authToken: String) async -> Result<String, Error> {
// Exchange short-lived auth token to a long-lived access token
let accessToken: String
public func exchangeAuthTokenToAccessToken(_ authToken: String) async -> Result<String, Error> {
switch await AuthService.getAccessToken(token: authToken) {
case .success(let response):
accessToken = response.accessToken
return .success(response.accessToken)
case .failure(let error):
os_log("AccountManager error: %{public}@", log: .error, error.localizedDescription)
return .failure(error)
}
}

public typealias AccountDetails = (email: String?, externalID: String)

// Fetch entitlements and account details and store the data
public func fetchAccountDetails(with accessToken: String) async -> Result<AccountDetails, Error> {
switch await AuthService.validateToken(accessToken: accessToken) {
case .success(let response):
self.storeAuthToken(token: authToken)
self.storeAccount(token: accessToken,
email: response.account.email,
externalID: response.account.externalID)

return .success(response.account.externalID)

return .success(AccountDetails(email: response.account.email, externalID: response.account.externalID))
case .failure(let error):
os_log("AccountManager error: %{public}@", log: .error, error.localizedDescription)
return .failure(error)
}
}

public func refreshAccountData() async {
guard let accessToken else { return }
public func checkSubscriptionState() async {
guard let token = accessToken else { return }

switch await AuthService.validateToken(accessToken: accessToken) {
case .success(let response):
self.storeAccount(token: accessToken,
email: response.account.email,
externalID: response.account.externalID)
case .failure:
break
if case .success(let response) = await SubscriptionService.getSubscriptionDetails(token: token) {
if !response.isSubscriptionActive {
signOut()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,22 +55,30 @@ public final class AppStorePurchaseFlow {
}

public static func purchaseSubscription(with subscriptionIdentifier: String, emailAccessToken: String?) async -> Result<Void, AppStorePurchaseFlow.Error> {
let accountManager = AccountManager()
let externalID: String

// Check for past transactions most recent

switch await AppStoreRestoreFlow.restoreAccountFromPastPurchase() {
case .success(let success):
guard !success.isActive else { return .failure(.activeSubscriptionAlreadyPresent)}
externalID = success.externalID
case .success:
return .failure(.activeSubscriptionAlreadyPresent)
case .failure(let error):
switch error {
case .missingAccountOrTransactions, .pastTransactionAuthenticationFailure:
case .subscriptionExpired(let expiredAccountDetails):
externalID = expiredAccountDetails.externalID
accountManager.storeAuthToken(token: expiredAccountDetails.authToken)
accountManager.storeAccount(token: expiredAccountDetails.accessToken, email: expiredAccountDetails.email, externalID: expiredAccountDetails.externalID)
case .missingAccountOrTransactions:
// No history, create new account
switch await AuthService.createAccount(emailAccessToken: emailAccessToken) {
case .success(let response):
externalID = response.externalID
await AccountManager().exchangeAndStoreTokens(with: response.authToken)

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:
return .failure(.accountCreationFailed)
}
Expand All @@ -93,7 +101,7 @@ public final class AppStorePurchaseFlow {
@discardableResult
public static func completeSubscriptionPurchase() async -> Result<PurchaseUpdate, AppStorePurchaseFlow.Error> {

let result = await checkForEntitlements(wait: 2.0, retry: 15)
let result = await checkForEntitlements(wait: 2.0, retry: 10)

return result ? .success(PurchaseUpdate(type: "completed")) : .failure(.missingEntitlements)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//

Check failure on line 1 in DuckDuckGo/PrivacyPro/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Header comments should be consistent with project patterns (file_header)
// 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

@available(macOS 12.0, iOS 15.0, *)
public final class AppStoreRestoreFlow {

// swiftlint:disable:next large_tuple
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)
case somethingWentWrong
}

public static func restoreAccountFromPastPurchase() async -> Result<Void, AppStoreRestoreFlow.Error> {
guard let lastTransactionJWSRepresentation = await PurchaseManager.mostRecentTransaction() else { return .failure(.missingAccountOrTransactions) }

Check failure on line 39 in DuckDuckGo/PrivacyPro/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line should be 150 characters or less; currently it has 154 characters (line_length)

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:
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:
return .failure(.failedToObtainAccessToken)
}

switch await accountManager.fetchAccountDetails(with: accessToken) {
case .success(let accountDetails):
email = accountDetails.email
externalID = accountDetails.externalID
case .failure:
return .failure(.failedToFetchAccountDetails)
}

var isSubscriptionActive = false

switch await SubscriptionService.getSubscriptionDetails(token: accessToken) {
case .success(let response):
isSubscriptionActive = response.isSubscriptionActive
case .failure:
return .failure(.somethingWentWrong)
}

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)
return .failure(.subscriptionExpired(accountDetails: details))
}
}
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//

Check failure on line 1 in DuckDuckGo/PrivacyPro/Subscription/Flows/Stripe/StripePurchaseFlow.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Header comments should be consistent with project patterns (file_header)
// 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

public final class StripePurchaseFlow {

public enum Error: Swift.Error {
case noProductsFound
case accountCreationFailed
}

public static func subscriptionOptions() async -> Result<SubscriptionOptions, StripePurchaseFlow.Error> {

guard case let .success(products) = await SubscriptionService.getProducts(), !products.isEmpty else { 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<PurchaseUpdate, StripePurchaseFlow.Error> {

var authToken: String = ""

switch await AuthService.createAccount(emailAccessToken: emailAccessToken) {
case .success(let response):
authToken = response.authToken
AccountManager().storeAuthToken(token: authToken)
case .failure:
return .failure(.accountCreationFailed)
}

return .success(PurchaseUpdate(type: "redirect", token: authToken))
}

public static func completeSubscriptionPurchase() async {
let accountManager = AccountManager()

if let authToken = accountManager.authToken {
print("Exchanging token")

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)
}
}

if #available(macOS 12.0, iOS 15.0, *) {
await AppStorePurchaseFlow.checkForEntitlements(wait: 2.0, retry: 5)
}
}
}
26 changes: 3 additions & 23 deletions DuckDuckGo/PrivacyPro/Subscription/PurchaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,9 @@ enum PurchaseManagerError: Error {
@available(macOS 12.0, iOS 15.0, *)
public final class PurchaseManager: ObservableObject {

static let productIdentifiers = ["subscription.1week",
"subscription.1month",
"subscription.1year",
"review.subscription.1week",
"review.subscription.1month",
"review.subscription.1year",
"ios.subscription.1month",
"ios.subscription.1year"]
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()

Expand Down Expand Up @@ -121,21 +116,6 @@ public final class PurchaseManager: ObservableObject {
}
}

@MainActor
func fetchAvailableProducts() async -> [Product] {
print(" -- [PurchaseManager] fetchAvailableProducts()")

do {
let availableProducts = try await Product.products(for: Self.productIdentifiers)
print(" -- [PurchaseManager] fetchAvailableProducts(): fetched \(availableProducts.count) products")

return availableProducts
} catch {
print("Error fetching available products: \(error)")
return []
}
}

@MainActor
public func updatePurchasedProducts() async {
print(" -- [PurchaseManager] updatePurchasedProducts()")
Expand Down
Loading

0 comments on commit 1735eb6

Please sign in to comment.