Skip to content
This repository has been archived by the owner on Feb 24, 2025. It is now read-only.

Commit

Permalink
Privacy Pro Free Trials - Models and API (#1120)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/1208114992212396/1208796999534221/f
iOS PR: duckduckgo/iOS#3691
macOS PR: duckduckgo/macos-browser#3641
What kind of version bump will this require?: Minor

**Optional**:

Tech Design URL:
https://app.asana.com/0/481882893211075/1208773437150501/f

**Description**: This PR includes the following:

* Adds a new model type for Subscription Offers
* Adds a new API to `StorePurchaseManager` to retrieve Free Trial
Subscriptions
* Updates existing `subscriptionOptions` API to NOT return Free Trial
Subscriptions
* Adds abstractions on StoreKit types to enable testing
* Adds tests (**Note: Make sure to expand the
`StorePurchaseManagerTests` file when reviewing**)
  • Loading branch information
aataraxiaa authored Dec 11, 2024
1 parent b7f7ba9 commit e8654e1
Show file tree
Hide file tree
Showing 10 changed files with 885 additions and 42 deletions.
21 changes: 21 additions & 0 deletions Sources/Subscription/Flows/Models/SubscriptionOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ public enum SubscriptionPlatformName: String, Encodable {
public struct SubscriptionOption: Encodable, Equatable {
let id: String
let cost: SubscriptionOptionCost
let offer: SubscriptionOptionOffer?

init(id: String, cost: SubscriptionOptionCost, offer: SubscriptionOptionOffer? = nil) {
self.id = id
self.cost = cost
self.offer = offer
}
}

struct SubscriptionOptionCost: Encodable, Equatable {
Expand All @@ -60,3 +67,17 @@ struct SubscriptionOptionCost: Encodable, Equatable {
public struct SubscriptionFeature: Encodable, Equatable {
let name: Entitlement.ProductName
}

/// A `SubscriptionOptionOffer` represents an offer (e.g Free Trials) associated with a Subscription
public struct SubscriptionOptionOffer: Encodable, Equatable {

public enum OfferType: String, Codable, CaseIterable {
case freeTrial
}

let type: OfferType
let id: String
let displayPrice: String
let durationInDays: Int
let isUserEligible: Bool
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// ProductFetching.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation
import StoreKit

/// A protocol for types that can fetch subscription products.
@available(macOS 12.0, iOS 15.0, *)
public protocol ProductFetching {
/// Fetches products for the specified identifiers.
/// - Parameter identifiers: An array of product identifiers to fetch.
/// - Returns: An array of subscription products.
/// - Throws: An error if the fetch operation fails.
func products(for identifiers: [String]) async throws -> [any SubscriptionProduct]
}

/// A default implementation of ProductFetching that uses StoreKit's standard product fetching.
@available(macOS 12.0, iOS 15.0, *)
public final class DefaultProductFetcher: ProductFetching {
/// Initializes a new DefaultProductFetcher instance.
public init() {}

/// Fetches products using StoreKit's Product.products API.
/// - Parameter identifiers: An array of product identifiers to fetch.
/// - Returns: An array of subscription products.
/// - Throws: An error if the fetch operation fails.
public func products(for identifiers: [String]) async throws -> [any SubscriptionProduct] {
return try await Product.products(for: identifiers)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,16 @@ public enum StorePurchaseManagerError: Error {
public protocol StorePurchaseManager {
typealias TransactionJWS = String

/// Returns the available subscription options that DON'T include Free Trial periods.
/// - Returns: A `SubscriptionOptions` object containing the available subscription plans and pricing,
/// or `nil` if no options are available or cannot be fetched.
func subscriptionOptions() async -> SubscriptionOptions?

/// Returns the subscription options that include Free Trial periods.
/// - Returns: A `SubscriptionOptions` object containing subscription plans with free trial offers,
/// or `nil` if no free trial options are available or the user is not eligible.
func freeTrialSubscriptionOptions() async -> SubscriptionOptions?

var purchasedProductIDs: [String] { get }
var purchaseQueue: [String] { get }
var areProductsAvailable: Bool { get }
Expand All @@ -61,7 +70,7 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM
private let subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache
private let subscriptionFeatureFlagger: FeatureFlaggerMapping<SubscriptionFeatureFlags>?

@Published public private(set) var availableProducts: [Product] = []
@Published public private(set) var availableProducts: [any SubscriptionProduct] = []
@Published public private(set) var purchasedProductIDs: [String] = []
@Published public private(set) var purchaseQueue: [String] = []

Expand All @@ -70,11 +79,15 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM
private var transactionUpdates: Task<Void, Never>?
private var storefrontChanges: Task<Void, Never>?

private var productFetcher: ProductFetching

public init(subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache,
subscriptionFeatureFlagger: FeatureFlaggerMapping<SubscriptionFeatureFlags>? = nil) {
subscriptionFeatureFlagger: FeatureFlaggerMapping<SubscriptionFeatureFlags>? = nil,
productFetcher: ProductFetching = DefaultProductFetcher()) {
self.storeSubscriptionConfiguration = DefaultStoreSubscriptionConfiguration()
self.subscriptionFeatureMappingCache = subscriptionFeatureMappingCache
self.subscriptionFeatureFlagger = subscriptionFeatureFlagger
self.productFetcher = productFetcher
transactionUpdates = observeTransactionUpdates()
storefrontChanges = observeStorefrontChanges()
}
Expand Down Expand Up @@ -104,40 +117,13 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM
}

public func subscriptionOptions() async -> SubscriptionOptions? {
Logger.subscription.info("[AppStorePurchaseFlow] subscriptionOptions")
let products = availableProducts
let monthly = products.first(where: { $0.subscription?.subscriptionPeriod.unit == .month && $0.subscription?.subscriptionPeriod.value == 1 })
let yearly = products.first(where: { $0.subscription?.subscriptionPeriod.unit == .year && $0.subscription?.subscriptionPeriod.value == 1 })
guard let monthly, let yearly else {
Logger.subscription.error("[AppStorePurchaseFlow] No products found")
return nil
}

let platform: SubscriptionPlatformName = {
#if os(iOS)
.ios
#else
.macos
#endif
}()

let options = [SubscriptionOption(id: monthly.id,
cost: .init(displayPrice: monthly.displayPrice, recurrence: "monthly")),
SubscriptionOption(id: yearly.id,
cost: .init(displayPrice: yearly.displayPrice, recurrence: "yearly"))]

let features: [SubscriptionFeature]

if let featureFlagger = subscriptionFeatureFlagger, featureFlagger.isFeatureOn(.isLaunchedROW) || featureFlagger.isFeatureOn(.isLaunchedROWOverride) {
features = await subscriptionFeatureMappingCache.subscriptionFeatures(for: monthly.id).compactMap { SubscriptionFeature(name: $0) }
} else {
let allFeatures: [Entitlement.ProductName] = [.networkProtection, .dataBrokerProtection, .identityTheftRestoration]
features = allFeatures.compactMap { SubscriptionFeature(name: $0) }
}
let nonFreeTrialProducts = availableProducts.filter { !$0.hasFreeTrialOffer }
return await subscriptionOptions(for: nonFreeTrialProducts)
}

return SubscriptionOptions(platform: platform,
options: options,
features: features)
public func freeTrialSubscriptionOptions() async -> SubscriptionOptions? {
let freeTrialProducts = availableProducts.filter { $0.hasFreeTrialOffer }
return await subscriptionOptions(for: freeTrialProducts)
}

@MainActor
Expand Down Expand Up @@ -165,10 +151,10 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM

self.currentStorefrontRegion = storefrontRegion
let applicableProductIdentifiers = storeSubscriptionConfiguration.subscriptionIdentifiers(for: storefrontRegion)
let availableProducts = try await Product.products(for: applicableProductIdentifiers)
let availableProducts = try await productFetcher.products(for: applicableProductIdentifiers)
Logger.subscription.info("[StorePurchaseManager] updateAvailableProducts fetched \(availableProducts.count) products for \(storefrontCountryCode ?? "<nil>", privacy: .public)")

if self.availableProducts != availableProducts {
if Set(availableProducts.map { $0.id }) != Set(self.availableProducts.map { $0.id }) {
self.availableProducts = availableProducts

// Update cached subscription features mapping
Expand Down Expand Up @@ -298,6 +284,40 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM
}
}

private func subscriptionOptions(for products: [any SubscriptionProduct]) async -> SubscriptionOptions? {
Logger.subscription.info("[AppStorePurchaseFlow] subscriptionOptions")
let monthly = products.first(where: { $0.isMonthly })
let yearly = products.first(where: { $0.isYearly })
guard let monthly, let yearly else {
Logger.subscription.error("[AppStorePurchaseFlow] No products found")
return nil
}

let platform: SubscriptionPlatformName = {
#if os(iOS)
.ios
#else
.macos
#endif
}()

let options: [SubscriptionOption] = await [.init(from: monthly, withRecurrence: "monthly"),
.init(from: yearly, withRecurrence: "yearly")]

let features: [SubscriptionFeature]

if let featureFlagger = subscriptionFeatureFlagger, featureFlagger.isFeatureOn(.isLaunchedROW) || featureFlagger.isFeatureOn(.isLaunchedROWOverride) {
features = await subscriptionFeatureMappingCache.subscriptionFeatures(for: monthly.id).compactMap { SubscriptionFeature(name: $0) }
} else {
let allFeatures: [Entitlement.ProductName] = [.networkProtection, .dataBrokerProtection, .identityTheftRestoration]
features = allFeatures.compactMap { SubscriptionFeature(name: $0) }
}

return SubscriptionOptions(platform: platform,
options: options,
features: features)
}

private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
// Check whether the JWS passes StoreKit verification.
switch result {
Expand Down Expand Up @@ -337,6 +357,24 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM
}
}

@available(macOS 12.0, iOS 15.0, *)
private extension SubscriptionOption {

init(from product: any SubscriptionProduct, withRecurrence recurrence: String) async {
var offer: SubscriptionOptionOffer?

if let introOffer = product.introductoryOffer, introOffer.isFreeTrial {

let durationInDays = introOffer.periodInDays
let isUserEligible = await product.isEligibleForIntroOffer

offer = .init(type: .freeTrial, id: introOffer.id ?? "", displayPrice: introOffer.displayPrice, durationInDays: durationInDays, isUserEligible: isUserEligible)
}

self.init(id: product.id, cost: .init(displayPrice: product.displayPrice, recurrence: recurrence), offer: offer)
}
}

public extension UserDefaults {

enum Constants {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//
// SubscriptionProduct.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation
import StoreKit

/// A protocol that defines the core functionality and properties of a subscription product.
/// Conforming types must provide information about pricing, description, and subscription terms.
@available(macOS 12.0, iOS 15.0, *)
public protocol SubscriptionProduct: Equatable {
/// The unique identifier of the product.
var id: String { get }

/// The user-facing name of the product.
var displayName: String { get }

/// The formatted price that should be displayed to users.
var displayPrice: String { get }

/// A detailed description of the product.
var description: String { get }

/// Indicates whether this is a monthly subscription.
var isMonthly: Bool { get }

/// Indicates whether this is a yearly subscription.
var isYearly: Bool { get }

/// The introductory offer associated with this subscription, if any.
var introductoryOffer: SubscriptionProductIntroductoryOffer? { get }

/// Indicates whether this subscription has a Free Trial offer available.
var hasFreeTrialOffer: Bool { get }

/// Asynchronously determines whether the user is eligible for an introductory offer.
var isEligibleForIntroOffer: Bool { get async }

/// Initiates a purchase of the subscription with the specified options.
/// - Parameter options: A set of options to configure the purchase.
/// - Returns: The result of the purchase attempt.
/// - Throws: An error if the purchase fails.
func purchase(options: Set<Product.PurchaseOption>) async throws -> Product.PurchaseResult
}

/// Extends StoreKit's Product to conform to SubscriptionProduct.
@available(macOS 12.0, iOS 15.0, *)
extension Product: SubscriptionProduct {
/// Determines if this is a monthly subscription by checking if the subscription period
/// is exactly one month.
public var isMonthly: Bool {
guard let subscription else { return false }
return subscription.subscriptionPeriod.unit == .month &&
subscription.subscriptionPeriod.value == 1
}

/// Determines if this is a yearly subscription by checking if the subscription period
/// is exactly one year.
public var isYearly: Bool {
guard let subscription else { return false }
return subscription.subscriptionPeriod.unit == .year &&
subscription.subscriptionPeriod.value == 1
}

/// Returns the introductory offer for this subscription if available.
public var introductoryOffer: (any SubscriptionProductIntroductoryOffer)? {
subscription?.introductoryOffer
}

/// Indicates whether this subscription has a Free Trial offer.
public var hasFreeTrialOffer: Bool {
return subscription?.introductoryOffer?.isFreeTrial ?? false
}

/// Asynchronously checks if the user is eligible for an introductory offer.
public var isEligibleForIntroOffer: Bool {
get async {
guard let subscription else { return false }
return await subscription.isEligibleForIntroOffer
}
}

/// Implements Equatable by comparing product IDs.
public static func == (lhs: Product, rhs: Product) -> Bool {
return lhs.id == rhs.id
}
}
Loading

0 comments on commit e8654e1

Please sign in to comment.