Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Privacy Pro Free Trials - Models and API #1120

Merged
merged 7 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading