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 6 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
}
265 changes: 228 additions & 37 deletions Sources/Subscription/Managers/StorePurchaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,157 @@ public enum StorePurchaseManagerError: Error {
case unknownError
}

/// A protocol that defines the properties of an introductory offer for a subscription product.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this file gets a bit bloated and may be a bit hard to wrap your head around, what do you think about making a StorePurchaseManager subfolder and split out the protocols, extensions etc. to separate files?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good suggestion @miasma13, I'll make that change.

/// Use this protocol to represent trial periods, introductory prices, or other special offers.
@available(macOS 12.0, iOS 15.0, *)
public protocol SubscriptionProductIntroductoryOffer {
/// The unique identifier of the introductory offer.
var id: String? { get }

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

/// The duration of the offer in days.
var periodInDays: Int { get }

/// Indicates whether this offer represents a free trial period.
var isFreeTrial: Bool { get }
}

/// Extends StoreKit's Product.SubscriptionOffer to conform to SubscriptionProductIntroductoryOffer.
@available(macOS 12.0, iOS 15.0, *)
extension Product.SubscriptionOffer: SubscriptionProductIntroductoryOffer {
/// Calculates the total number of days in the offer period by multiplying
/// the base period length by the period count.
public var periodInDays: Int {
period.periodInDays * periodCount
}

/// Determines if this offer represents a free trial based on the payment mode.
public var isFreeTrial: Bool {
paymentMode == .freeTrial
}
}

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

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

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 +208,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 +217,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 +255,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 +289,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 +422,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 +495,39 @@ 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)
}
}

@available(macOS 12.0, iOS 15.0, *)
private extension Product.SubscriptionPeriod {

var periodInDays: Int {
switch unit {
case .day: return value
case .week: return value * 7
case .month: return value * 30
case .year: return value * 365
@unknown default:
return value
}
}
}

public extension UserDefaults {

enum Constants {
Expand Down
4 changes: 3 additions & 1 deletion Sources/Subscription/StoreSubscriptionConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ final class DefaultStoreSubscriptionConfiguration: StoreSubscriptionConfiguratio
appIdentifier: "com.duckduckgo.mobile.ios.alpha",
environment: .staging,
identifiersByRegion: [.usa: ["ios.subscription.1month",
"ios.subscription.1year"],
"ios.subscription.1year",
"ios.subscription.1month.freetrial.dev",
"ios.subscription.1year.freetrial.dev"],
.restOfWorld: ["ios.subscription.1month.row",
"ios.subscription.1year.row"]]),
// macOS debug build
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ import Foundation
import Subscription

public final class StorePurchaseManagerMock: StorePurchaseManager {

public var purchasedProductIDs: [String] = []
public var purchaseQueue: [String] = []
public var areProductsAvailable: Bool = false
public var currentStorefrontRegion: SubscriptionRegion = .usa

public var subscriptionOptionsResult: SubscriptionOptions?
public var freeTrialSubscriptionOptionsResult: SubscriptionOptions?
public var syncAppleIDAccountResultError: Error?

public var mostRecentTransactionResult: String?
Expand All @@ -44,6 +46,10 @@ public final class StorePurchaseManagerMock: StorePurchaseManager {
subscriptionOptionsResult
}

public func freeTrialSubscriptionOptions() async -> SubscriptionOptions? {
freeTrialSubscriptionOptionsResult
}

public func syncAppleIDAccount() async throws {
if let syncAppleIDAccountResultError {
throw syncAppleIDAccountResultError
Expand Down
Loading
Loading