Skip to content

Commit

Permalink
Add new API to StorePurchaseManager to fetch Free Trial Subscriptions…
Browse files Browse the repository at this point in the history
…. Decorate SubscriptionOption with Free Trial info. Abstract StoreKit types to enabled testing. Add tests.
  • Loading branch information
aataraxiaa committed Dec 6, 2024
1 parent ea81857 commit 04a2e89
Show file tree
Hide file tree
Showing 4 changed files with 803 additions and 37 deletions.
266 changes: 229 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.
/// 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 All @@ -180,6 +304,7 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM
Logger.subscription.error("[StorePurchaseManager] Error: \(String(reflecting: error), privacy: .public)")
}
}

Check failure on line 307 in Sources/Subscription/Managers/StorePurchaseManager.swift

View workflow job for this annotation

GitHub Actions / Run SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)

Check failure on line 308 in Sources/Subscription/Managers/StorePurchaseManager.swift

View workflow job for this annotation

GitHub Actions / Run SwiftLint

Limit vertical whitespace to a single empty line; currently 2 (vertical_whitespace)
@MainActor
public func updatePurchasedProducts() async {
Expand Down Expand Up @@ -298,6 +423,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 +496,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
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 {

Check failure on line 23 in Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift

View workflow job for this annotation

GitHub Actions / Run SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,16 @@ import Subscription

public final class SubscriptionFeatureMappingCacheMock: SubscriptionFeatureMappingCache {

public var didCallSubscriptionFeatures = false
public var lastCalledSubscriptionId: String?

public var mapping: [String: [Entitlement.ProductName]] = [:]

public init() { }

public func subscriptionFeatures(for subscriptionIdentifier: String) async -> [Entitlement.ProductName] {
didCallSubscriptionFeatures = true
lastCalledSubscriptionId = subscriptionIdentifier
return mapping[subscriptionIdentifier] ?? []
}
}
Loading

0 comments on commit 04a2e89

Please sign in to comment.