diff --git a/CHANGELOG.md b/CHANGELOG.md index 93c4c4762..63760b0ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ## Added +- Implement Support for Promotional Offers + - Added in Pull Request [#16](https://github.com/space-code/flare/pull/16). + - Add additional badges to `README.md` - Added in Pull Request [#15](https://github.com/space-code/flare/pull/15). @@ -55,4 +58,4 @@ Released on 2023-01-20. #### Added - Initial release of Flare. - - Added by [Nikita Vasilev](https://github.com/nik3212). \ No newline at end of file + - Added by [Nikita Vasilev](https://github.com/nik3212). diff --git a/README.md b/README.md index c1038168d..a72bf09c7 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,9 @@ Flare is a framework written in Swift that makes it easy for you to work with in ## Features - [x] Support Consumable & Non-Consumable Purchases - [x] Support Subscription Purchase -- [x] Refresh Receipt -- [x] Complete Unit Test Coverage +- [x] Support Promotional & Introductory Offers +- [x] iOS, tvOS, watchOS, macOS, and visionOS compatible +- [x] Complete Unit & Integration Test Coverage ## Documentation Check out [flare documentation](https://space-code.github.io/flare/documentation/flare/). diff --git a/Sources/Flare/Classes/DI/FlareDependencies.swift b/Sources/Flare/Classes/DI/FlareDependencies.swift new file mode 100644 index 000000000..5f50073af --- /dev/null +++ b/Sources/Flare/Classes/DI/FlareDependencies.swift @@ -0,0 +1,70 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Concurrency +import Foundation +import StoreKit + +/// The package's dependencies. +final class FlareDependencies: IFlareDependencies { + // MARK: Internal + + lazy var iapProvider: IIAPProvider = IAPProvider( + paymentQueue: SKPaymentQueue.default(), + productProvider: productProvider, + purchaseProvider: purchaseProvider, + receiptRefreshProvider: receiptRefreshProvider, + refundProvider: refundProvider, + eligibilityProvider: eligibilityProvider, + redeemCodeProvider: redeemCodeProvider + ) + + lazy var configurationProvider: IConfigurationProvider = ConfigurationProvider() + + // MARK: Private + + private var productProvider: IProductProvider { + ProductProvider( + dispatchQueueFactory: DispatchQueueFactory() + ) + } + + private var purchaseProvider: IPurchaseProvider { + PurchaseProvider( + paymentProvider: paymentProvider, + configurationProvider: configurationProvider + ) + } + + private var paymentProvider: IPaymentProvider { + PaymentProvider( + paymentQueue: SKPaymentQueue.default(), + dispatchQueueFactory: DispatchQueueFactory() + ) + } + + private var receiptRefreshProvider: IReceiptRefreshProvider { + ReceiptRefreshProvider( + dispatchQueueFactory: DispatchQueueFactory(), + receiptRefreshRequestFactory: ReceiptRefreshRequestFactory() + ) + } + + private var refundProvider: IRefundProvider { + RefundProvider( + systemInfoProvider: SystemInfoProvider() + ) + } + + private var eligibilityProvider: IEligibilityProvider { + EligibilityProvider() + } + + private var redeemCodeProvider: IRedeemCodeProvider { + RedeemCodeProvider(systemInfoProvider: systemInfoProvider) + } + + private lazy var systemInfoProvider: ISystemInfoProvider = SystemInfoProvider() +} diff --git a/Sources/Flare/Classes/DI/IFlareDependencies.swift b/Sources/Flare/Classes/DI/IFlareDependencies.swift new file mode 100644 index 000000000..e962f74f3 --- /dev/null +++ b/Sources/Flare/Classes/DI/IFlareDependencies.swift @@ -0,0 +1,14 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// The package's dependencies. +protocol IFlareDependencies { + /// The IAP provider. + var iapProvider: IIAPProvider { get } + /// The configuration provider. + var configurationProvider: IConfigurationProvider { get } +} diff --git a/Sources/Flare/Classes/Flare.swift b/Sources/Flare/Classes/Flare.swift index f27396447..c1db74c81 100644 --- a/Sources/Flare/Classes/Flare.swift +++ b/Sources/Flare/Classes/Flare.swift @@ -13,24 +13,41 @@ import StoreKit /// The class creates and manages in-app purchases. public final class Flare { + // MARK: Properties + + /// The in-app purchase provider. + private let iapProvider: IIAPProvider + + /// The configuration provider. + private let configurationProvider: IConfigurationProvider + + /// The singleton instance. + private static let flare: Flare = .init() + + /// Returns a shared `Flare` object. + public static var shared: IFlare { flare } + // MARK: Initialization /// Creates a new `Flare` instance. /// - /// - Parameter iapProvider: The in-app purchase provider. - init(iapProvider: IIAPProvider = IAPProvider()) { - self.iapProvider = iapProvider + /// - Parameters: + /// - dependencies: The package's dependencies. + /// - configurationProvider: The configuration provider. + init(dependencies: IFlareDependencies = FlareDependencies()) { + iapProvider = dependencies.iapProvider + configurationProvider = dependencies.configurationProvider } // MARK: Public - /// Returns a default `Flare` object. - public static let `default`: IFlare = Flare() - - // MARK: Private - - /// The in-app purchase provider. - private let iapProvider: IIAPProvider + /// Configures the Flare package with the provided configuration. + /// + /// - Parameters: + /// - configuration: The configuration object containing settings for Flare. + public static func configure(with configuration: Configuration) { + flare.configurationProvider.configure(with: configuration) + } } // MARK: IFlare @@ -44,13 +61,17 @@ extension Flare: IFlare { try await iapProvider.fetch(productIDs: productIDs) } - public func purchase(product: StoreProduct, completion: @escaping Closure>) { + public func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: @escaping Closure> + ) { guard iapProvider.canMakePayments else { completion(.failure(.paymentNotAllowed)) return } - iapProvider.purchase(product: product) { result in + iapProvider.purchase(product: product, promotionalOffer: promotionalOffer) { result in switch result { case let .success(transaction): completion(.success(transaction)) @@ -60,31 +81,33 @@ extension Flare: IFlare { } } - public func purchase(product: StoreProduct) async throws -> StoreTransaction { + public func purchase(product: StoreProduct, promotionalOffer: PromotionalOffer?) async throws -> StoreTransaction { guard iapProvider.canMakePayments else { throw IAPError.paymentNotAllowed } - return try await iapProvider.purchase(product: product) + return try await iapProvider.purchase(product: product, promotionalOffer: promotionalOffer) } @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) public func purchase( product: StoreProduct, options: Set, + promotionalOffer: PromotionalOffer?, completion: @escaping SendableClosure> ) { guard iapProvider.canMakePayments else { completion(.failure(.paymentNotAllowed)) return } - iapProvider.purchase(product: product, options: options, completion: completion) + iapProvider.purchase(product: product, options: options, promotionalOffer: promotionalOffer, completion: completion) } @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) public func purchase( product: StoreProduct, - options: Set + options: Set, + promotionalOffer: PromotionalOffer? ) async throws -> StoreTransaction { guard iapProvider.canMakePayments else { throw IAPError.paymentNotAllowed } - return try await iapProvider.purchase(product: product, options: options) + return try await iapProvider.purchase(product: product, options: options, promotionalOffer: promotionalOffer) } public func receipt(completion: @escaping Closure>) { @@ -114,6 +137,11 @@ extension Flare: IFlare { iapProvider.removeTransactionObserver() } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + public func checkEligibility(productIDs: Set) async throws -> [String: SubscriptionEligibility] { + try await iapProvider.checkEligibility(productIDs: productIDs) + } + #if os(iOS) || VISION_OS @available(iOS 15.0, *) @available(macOS, unavailable) @@ -122,5 +150,21 @@ extension Flare: IFlare { public func beginRefundRequest(productID: String) async throws -> RefundRequestStatus { try await iapProvider.beginRefundRequest(productID: productID) } + + @available(iOS 14.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + public func presentCodeRedemptionSheet() { + iapProvider.presentCodeRedemptionSheet() + } + + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + public func presentOfferCodeRedeemSheet() async throws { + try await iapProvider.presentOfferCodeRedeemSheet() + } #endif } diff --git a/Sources/Flare/Classes/Foundation/UserDefaults/IUserDefaults.swift b/Sources/Flare/Classes/Foundation/UserDefaults/IUserDefaults.swift new file mode 100644 index 000000000..dfbd224b4 --- /dev/null +++ b/Sources/Flare/Classes/Foundation/UserDefaults/IUserDefaults.swift @@ -0,0 +1,24 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Protocol for managing `UserDefaults` operations. +protocol IUserDefaults { + /// Sets a `Codable` value in `UserDefaults` for a given key. + /// + /// - Parameters: + /// - key: The key to associate with the Codable value. + /// + /// - codable: The Codable value to be stored. + func set(key: String, codable: T) + + /// Retrieves a `Codable` value from `UserDefaults` for a given key. + /// + /// - Parameter key: The key associated with the desired Codable value. + /// + /// - Returns: The Codable value stored for the given key, or nil if not found. + func get(key: String) -> T? +} diff --git a/Sources/Flare/Classes/Foundation/UserDefaults/UserDefaults.swift b/Sources/Flare/Classes/Foundation/UserDefaults/UserDefaults.swift new file mode 100644 index 000000000..e280286e2 --- /dev/null +++ b/Sources/Flare/Classes/Foundation/UserDefaults/UserDefaults.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +extension UserDefaults: IUserDefaults { + func set(key: String, codable: T) { + guard let value = try? JSONEncoder().encode(codable) else { return } + set(value, forKey: key) + } + + func get(key: String) -> T? { + let data = object(forKey: key) as? Data + guard let data = data, let value = try? JSONDecoder().decode(T.self, from: data) else { return nil } + return value + } +} diff --git a/Sources/Flare/Classes/Helpers/PaymentQueue/PaymentQueue.swift b/Sources/Flare/Classes/Helpers/PaymentQueue/PaymentQueue.swift index 6bcfe38e6..5b2783f3f 100644 --- a/Sources/Flare/Classes/Helpers/PaymentQueue/PaymentQueue.swift +++ b/Sources/Flare/Classes/Helpers/PaymentQueue/PaymentQueue.swift @@ -35,4 +35,10 @@ public protocol PaymentQueue: AnyObject { /// Remove a finished (i.e. failed or completed) transaction from the queue. /// Attempting to finish a purchasing transaction will throw an exception. func finishTransaction(_ transaction: SKPaymentTransaction) + + #if os(iOS) || VISION_OS + // Call this method to have StoreKit present a sheet enabling the user to redeem codes provided by your app. + @available(iOS 14.0, *) + func presentCodeRedemptionSheet() + #endif } diff --git a/Sources/Flare/Classes/IFlare.swift b/Sources/Flare/Classes/IFlare.swift index 6643cdd14..48e0eef63 100644 --- a/Sources/Flare/Classes/IFlare.swift +++ b/Sources/Flare/Classes/IFlare.swift @@ -6,6 +6,8 @@ import Foundation import StoreKit +// MARK: - IFlare + /// `Flare` creates and manages in-app purchases. public protocol IFlare { /// Retrieves localized information from the App Store about a specified list of products. @@ -32,8 +34,13 @@ public protocol IFlare { /// /// - Parameters: /// - product: The product to be purchased. + /// - promotionalOffer: The promotional offer. /// - completion: The closure to be executed once the purchase is complete. - func purchase(product: StoreProduct, completion: @escaping Closure>) + func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: @escaping Closure> + ) /// Purchases a product. /// @@ -41,12 +48,14 @@ public protocol IFlare { /// If the user can't make a payment, the method returns an error /// with the type `IAPError.paymentNotAllowed`. /// - /// - Parameter product: The product to be purchased. + /// - Parameters: + /// - product: The product to be purchased. + /// - promotionalOffer: The promotional offer. /// /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. /// /// - Returns: A payment transaction. - func purchase(product: StoreProduct) async throws -> StoreTransaction + func purchase(product: StoreProduct, promotionalOffer: PromotionalOffer?) async throws -> StoreTransaction /// Purchases a product. /// @@ -57,6 +66,7 @@ public protocol IFlare { /// - Parameters: /// - product: The product to be purchased. /// - options: The optional settings for a product purchase. + /// - promotionalOffer: The promotional offer. /// - completion: The closure to be executed once the purchase is complete. /// /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. @@ -66,6 +76,7 @@ public protocol IFlare { func purchase( product: StoreProduct, options: Set, + promotionalOffer: PromotionalOffer?, completion: @escaping SendableClosure> ) @@ -78,12 +89,17 @@ public protocol IFlare { /// - Parameters: /// - product: The product to be purchased. /// - options: The optional settings for a product purchase. + /// - promotionalOffer: The promotional offer. /// /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. /// /// - Returns: A payment transaction. @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func purchase(product: StoreProduct, options: Set) async throws -> StoreTransaction + func purchase( + product: StoreProduct, + options: Set, + promotionalOffer: PromotionalOffer? + ) async throws -> StoreTransaction /// Refreshes the receipt, representing the user's transactions with your app. /// @@ -116,6 +132,14 @@ public protocol IFlare { /// - Note: This may require that the user authenticate. func removeTransactionObserver() + /// Checks whether products are eligible for promotional offers + /// + /// - Parameter productIDs: The list of product identifiers for which you wish to check eligibility. + /// + /// - Returns: An array that contains information about the eligibility of products. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func checkEligibility(productIDs: Set) async throws -> [String: SubscriptionEligibility] + #if os(iOS) || VISION_OS /// Present the refund request sheet for the specified transaction in a window scene. /// @@ -127,5 +151,98 @@ public protocol IFlare { @available(watchOS, unavailable) @available(tvOS, unavailable) func beginRefundRequest(productID: String) async throws -> RefundRequestStatus + + /// Displays a sheet that enables users to redeem subscription offer codes that you configure in App Store Connect. + @available(iOS 14.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func presentCodeRedemptionSheet() + + /// Displays a sheet in the window scene that enables users to redeem + /// a subscription offer code that you configure in App Store + /// Connect. + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func presentOfferCodeRedeemSheet() async throws #endif } + +public extension IFlare { + /// Performs a purchase of a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - completion: The closure to be executed once the purchase is complete. + func purchase( + product: StoreProduct, + completion: @escaping Closure> + ) { + purchase(product: product, promotionalOffer: nil, completion: completion) + } + + /// Purchases a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameter product: The product to be purchased. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + func purchase(product: StoreProduct) async throws -> StoreTransaction { + try await purchase(product: product, promotionalOffer: nil) + } + + /// Purchases a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// - completion: The closure to be executed once the purchase is complete. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + completion: @escaping SendableClosure> + ) { + purchase(product: product, options: options, promotionalOffer: nil, completion: completion) + } + + /// Purchases a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set + ) async throws -> StoreTransaction { + try await purchase(product: product, options: options, promotionalOffer: nil) + } +} diff --git a/Sources/Flare/Classes/Models/Configuration.swift b/Sources/Flare/Classes/Models/Configuration.swift new file mode 100644 index 000000000..d2ece895c --- /dev/null +++ b/Sources/Flare/Classes/Models/Configuration.swift @@ -0,0 +1,27 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +public struct Configuration { + // MARK: Properties + + // swiftlint:disable:next line_length + // https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/subscriptions_and_offers/implementing_promotional_offers_in_your_app + + /// A string that associates the transaction with a user account on your service. + /// + /// - Important: You must set `applicationUsername` to be the same as the one used to generate the signature. + public let applicationUsername: String + + // MARK: Initialization + + /// Creates a `Configuration` instance. + /// + /// - Parameter applicationUsername: A string that associates the transaction with a user account on your service. + public init(applicationUsername: String) { + self.applicationUsername = applicationUsername + } +} diff --git a/Sources/Flare/Classes/Models/DiscountType.swift b/Sources/Flare/Classes/Models/DiscountType.swift new file mode 100644 index 000000000..37f49187f --- /dev/null +++ b/Sources/Flare/Classes/Models/DiscountType.swift @@ -0,0 +1,53 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - DiscountType + +/// The type of discount offer. +public enum DiscountType: Int, Sendable { + /// Introductory offer + case introductory = 0 + /// Promotional offer for subscriptions + case promotional = 1 +} + +extension DiscountType { + /// Creates a ``DiscountType`` instance. + /// + /// - Parameter productDiscount: The details of an introductory offer or a promotional + /// offer for an auto-renewable subscription. + /// + /// - Returns: A discount type. + static func from(productDiscount: SKProductDiscount) -> Self? { + switch productDiscount.type { + case .introductory: + return .introductory + case .subscription: + return .promotional + @unknown default: + return nil + } + } + + /// Creates a ``DiscountType`` instance. + /// + /// - Parameter discount: Information about a subscription offer that you configure in App Store Connect. + /// + /// - Returns: A discount type. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + static func from(discount: Product.SubscriptionOffer) -> Self? { + switch discount.type { + case Product.SubscriptionOffer.OfferType.introductory: + return .introductory + case Product.SubscriptionOffer.OfferType.promotional: + return .promotional + default: + return nil + } + } +} diff --git a/Sources/Flare/Classes/Models/IAPError.swift b/Sources/Flare/Classes/Models/IAPError.swift index 83537685e..80de297b9 100644 --- a/Sources/Flare/Classes/Models/IAPError.swift +++ b/Sources/Flare/Classes/Models/IAPError.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import StoreKit @@ -34,10 +34,14 @@ public enum IAPError: Swift.Error { /// /// - Note: This is only available for StoreKit 2 transactions. case verification(error: VerificationError) - /// + /// The purchase is pending, and requires action from the customer. /// /// - Note: This is only available for StoreKit 2 transactions. case paymentDefferred + /// The decoding signature is failed. + /// + /// - Note: This is only available for StoreKit 2 transactions. + case failedToDecodeSignature(signature: String) /// The unknown error occurred. case unknown } @@ -123,6 +127,8 @@ extension IAPError: Equatable { return lhs == rhs case (.unknown, .unknown): return true + case let (.failedToDecodeSignature(lhs), .failedToDecodeSignature(rhs)): + return lhs == rhs default: return false } diff --git a/Sources/Flare/Classes/Models/Internal/ProductsRequest.swift b/Sources/Flare/Classes/Models/Internal/ProductsRequest.swift new file mode 100644 index 000000000..21be0b315 --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/ProductsRequest.swift @@ -0,0 +1,39 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +/// A class that represents a request to the App Store. +final class ProductsRequest: ISKRequest { + // MARK: Properties + + /// The request. + private let request: SKRequest + + /// The request’s identifier. + var id: String { request.id } + + // MARK: Initialization + + /// Creates a `ProductsRequest` instance. + /// + /// - Parameter request: The request. + init(_ request: SKRequest) { + self.request = request + } + + // MARK: Hashable + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + // MARK: Equatable + + static func == (lhs: ProductsRequest, rhs: ProductsRequest) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift b/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift index 825632ca4..d649481ef 100644 --- a/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift +++ b/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import Foundation @@ -33,4 +33,13 @@ protocol ISKProduct { /// The subscription period for the product, if applicable. var subscriptionPeriod: SubscriptionPeriod? { get } + + /// The details of an introductory offer for an auto-renewable subscription. + var introductoryDiscount: StoreProductDiscount? { get } + + /// The details of promotional offers for an auto-renewable subscription. + var discounts: [StoreProductDiscount] { get } + + /// The subscription group identifier. + var subscriptionGroupIdentifier: String? { get } } diff --git a/Sources/Flare/Classes/Models/Internal/Protocols/ISKRequest.swift b/Sources/Flare/Classes/Models/Internal/Protocols/ISKRequest.swift new file mode 100644 index 000000000..5ec7b4fc1 --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/Protocols/ISKRequest.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Type that represents a request to the App Store. +protocol ISKRequest: Hashable { + /// The request’s identifier. + var id: String { get } +} diff --git a/Sources/Flare/Classes/Models/Internal/Protocols/IStoreProductDiscount.swift b/Sources/Flare/Classes/Models/Internal/Protocols/IStoreProductDiscount.swift new file mode 100644 index 000000000..903ba0cfa --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/Protocols/IStoreProductDiscount.swift @@ -0,0 +1,32 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - IStoreProductDiscount Protocol + +/// A protocol representing a discount information for a store product. +protocol IStoreProductDiscount: Sendable { + /// A unique identifier for the discount offer. + var offerIdentifier: String? { get } + + /// The currency code for the discount amount. + var currencyCode: String? { get } + + /// The discounted price in the specified currency. + var price: Decimal { get } + + /// The payment mode associated with the discount (e.g., freeTrial, payUpFront, payAsYouGo). + var paymentMode: PaymentMode { get } + + /// The period for which the discount is applicable in a subscription. + var subscriptionPeriod: SubscriptionPeriod { get } + + /// The number of subscription periods for which the discount is applied. + var numberOfPeriods: Int { get } + + /// The type of discount (e.g., introductory, promotional). + var type: DiscountType { get } +} diff --git a/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift b/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift index 92d48806c..44722d38a 100644 --- a/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift +++ b/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import Foundation @@ -56,17 +56,25 @@ extension SK1StoreProduct: ISKProduct { } var productCategory: ProductCategory? { - guard #available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) else { - return .nonSubscription - } - return subscriptionPeriod == nil ? .nonSubscription : .subscription + subscriptionPeriod == nil ? .nonSubscription : .subscription } - @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) var subscriptionPeriod: SubscriptionPeriod? { guard let subscriptionPeriod = product.subscriptionPeriod, subscriptionPeriod.numberOfUnits > 0 else { return nil } return SubscriptionPeriod.from(subscriptionPeriod: subscriptionPeriod) } + + var introductoryDiscount: StoreProductDiscount? { + product.introductoryPrice.flatMap { StoreProductDiscount(skProductDiscount: $0) } + } + + var discounts: [StoreProductDiscount] { + product.discounts.compactMap { StoreProductDiscount(skProductDiscount: $0) } + } + + var subscriptionGroupIdentifier: String? { + product.subscriptionGroupIdentifier + } } diff --git a/Sources/Flare/Classes/Models/Internal/SK1StoreProductDiscount.swift b/Sources/Flare/Classes/Models/Internal/SK1StoreProductDiscount.swift new file mode 100644 index 000000000..263bee9d2 --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/SK1StoreProductDiscount.swift @@ -0,0 +1,60 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +/// The details of an introductory offer or a promotional offer for an auto-renewable subscription. +struct SK1StoreProductDiscount: IStoreProductDiscount { + // MARK: Properties + + private let productDiscount: SKProductDiscount + + /// A unique identifier for the discount offer. + let offerIdentifier: String? + + /// The currency code for the discount amount. + let currencyCode: String? + + /// The discounted price in the specified currency. + let price: Decimal + + /// The payment mode associated with the discount (e.g., freeTrial, payUpFront, payAsYouGo). + let paymentMode: PaymentMode + + /// The period for which the discount is applicable in a subscription. + let subscriptionPeriod: SubscriptionPeriod + + /// The number of subscription periods for which the discount is applied. + let numberOfPeriods: Int + + /// The type of discount (e.g., introductory, promotional). + let type: DiscountType + + // MARK: Initialization + + /// Creates a `SK1StoreProductDiscount` instance. + /// + /// - Parameter productDiscount: The details of an introductory offer or a promotional + /// offer for an auto-renewable subscription. + init?(productDiscount: SKProductDiscount) { + guard let paymentMode = PaymentMode.from(productDiscount: productDiscount), + let discountType = DiscountType.from(productDiscount: productDiscount), + let subscriptionPeriod = SubscriptionPeriod.from(subscriptionPeriod: productDiscount.subscriptionPeriod) + else { + return nil + } + + self.productDiscount = productDiscount + + offerIdentifier = productDiscount.identifier + currencyCode = productDiscount.priceLocale.currencyCodeID + price = productDiscount.price as Decimal + self.paymentMode = paymentMode + self.subscriptionPeriod = subscriptionPeriod + numberOfPeriods = productDiscount.numberOfPeriods + type = discountType + } +} diff --git a/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift b/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift index c8fa0527d..ad99a8987 100644 --- a/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift +++ b/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import Foundation @@ -14,6 +14,7 @@ final class SK2StoreProduct { /// The store kit product. let product: StoreKit.Product + /// The currency format. private var currencyFormat: Decimal.FormatStyle.Currency { product.priceFormatStyle @@ -68,4 +69,20 @@ extension SK2StoreProduct: ISKProduct { } return SubscriptionPeriod.from(subscriptionPeriod: subscriptionPeriod) } + + var introductoryDiscount: StoreProductDiscount? { + product.subscription?.introductoryOffer.flatMap { + StoreProductDiscount(discount: $0, currencyCode: self.currencyCode) + } + } + + var discounts: [StoreProductDiscount] { + product.subscription?.promotionalOffers.compactMap { + StoreProductDiscount(discount: $0, currencyCode: self.currencyCode) + } ?? [] + } + + var subscriptionGroupIdentifier: String? { + product.subscription?.subscriptionGroupID + } } diff --git a/Sources/Flare/Classes/Models/Internal/SK2StoreProductDiscount.swift b/Sources/Flare/Classes/Models/Internal/SK2StoreProductDiscount.swift new file mode 100644 index 000000000..f38ba0cef --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/SK2StoreProductDiscount.swift @@ -0,0 +1,62 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +/// The details of an introductory offer or a promotional offer for an auto-renewable subscription. +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +struct SK2StoreProductDiscount: IStoreProductDiscount, Sendable { + // MARK: Properties + + private let subscriptionOffer: StoreKit.Product.SubscriptionOffer + + /// A unique identifier for the discount offer. + let offerIdentifier: String? + + /// The currency code for the discount amount. + let currencyCode: String? + + /// The discounted price in the specified currency. + let price: Decimal + + /// The payment mode associated with the discount (e.g., freeTrial, payUpFront, payAsYouGo). + let paymentMode: PaymentMode + + /// The period for which the discount is applicable in a subscription. + let subscriptionPeriod: SubscriptionPeriod + + /// The number of subscription periods for which the discount is applied. + let numberOfPeriods: Int + + /// The type of discount (e.g., introductory, promotional). + let type: DiscountType + + // MARK: Initialization + + /// Creates a `SK2StoreProductDiscount` instance. + /// + /// - Parameters: + /// - subscriptionOffer: Information about a subscription offer that you configure in App Store Connect. + /// - currencyCode: The currency code for the discount amount. + init?(subscriptionOffer: StoreKit.Product.SubscriptionOffer, currencyCode: String?) { + guard let paymentMode = PaymentMode.from(discount: subscriptionOffer), + let discountType = DiscountType.from(discount: subscriptionOffer), + let subscriptionPeriod = SubscriptionPeriod.from(subscriptionPeriod: subscriptionOffer.period) + else { + return nil + } + + self.subscriptionOffer = subscriptionOffer + + offerIdentifier = subscriptionOffer.id + self.currencyCode = currencyCode + price = subscriptionOffer.price + self.paymentMode = paymentMode + self.subscriptionPeriod = subscriptionPeriod + numberOfPeriods = subscriptionOffer.periodCount + type = discountType + } +} diff --git a/Sources/Flare/Classes/Models/PaymentMode.swift b/Sources/Flare/Classes/Models/PaymentMode.swift new file mode 100644 index 000000000..963694926 --- /dev/null +++ b/Sources/Flare/Classes/Models/PaymentMode.swift @@ -0,0 +1,59 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - PaymentMode + +/// The offer's payment mode. +public enum PaymentMode: Int, Sendable { + /// Price is charged one or more times + case payAsYouGo = 0 + /// Price is charged once in advance + case payUpFront = 1 + /// No initial charge + case freeTrial = 2 +} + +extension PaymentMode { + /// Creates a ``PaymentMode`` instance. + /// + /// - Parameter productDiscount: The details of an introductory offer or a promotional + /// offer for an auto-renewable subscription. + /// + /// - Returns: A payment mode. + static func from(productDiscount: SKProductDiscount) -> Self? { + switch productDiscount.paymentMode { + case .payAsYouGo: + return .payAsYouGo + case .payUpFront: + return .payUpFront + case .freeTrial: + return .freeTrial + @unknown default: + return nil + } + } + + /// Creates a ``PaymentMode`` instance. + /// + /// - Parameter discount: Information about a subscription offer that you configure in App Store Connect. + /// + /// - Returns: A payment mode. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + static func from(discount: Product.SubscriptionOffer) -> Self? { + switch discount.paymentMode { + case Product.SubscriptionOffer.PaymentMode.freeTrial: + return .freeTrial + case Product.SubscriptionOffer.PaymentMode.payAsYouGo: + return .payAsYouGo + case Product.SubscriptionOffer.PaymentMode.payUpFront: + return .payUpFront + default: + return nil + } + } +} diff --git a/Sources/Flare/Classes/Models/PromotionalOffer.swift b/Sources/Flare/Classes/Models/PromotionalOffer.swift new file mode 100644 index 000000000..c5a279c5c --- /dev/null +++ b/Sources/Flare/Classes/Models/PromotionalOffer.swift @@ -0,0 +1,116 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - PromotionalOffer + +/// A class representing a promotional offer. +public final class PromotionalOffer: NSObject, Sendable { + // MARK: Properties + + /// The details of an introductory offer or a promotional offer for an auto-renewable subscription. + public let discount: StoreProductDiscount + /// The signed discount applied to a payment. + public let signedData: SignedData + + // MARK: Initialization + + /// Creates a `PromotionalOffer` instance. + /// + /// - Parameters: + /// - discount: The details of an introductory offer or a promotional offer for an auto-renewable subscription. + /// - signedData: The signed discount applied to a payment. + public init(discount: StoreProductDiscount, signedData: SignedData) { + self.discount = discount + self.signedData = signedData + } +} + +// MARK: PromotionalOffer.SignedData + +public extension PromotionalOffer { + /// The signed discount applied to a payment. + final class SignedData: NSObject, Sendable { + // MARK: Properties + + /// The identifier agreed upon with the App Store for a discount of your choosing. + public let identifier: String + /// The identifier of the public/private key pair agreed upon with the App Store when the keys were generated. + public let keyIdentifier: String + /// One-time use random entropy-adding value for security. + public let nonce: UUID + /// The cryptographic signature generated by your private key. + public let signature: String + /// Timestamp of when the signature is created. + public let timestamp: Int + + /// Creates a `SignedData` instance. + /// + /// - Parameters: + /// - identifier: The identifier agreed upon with the App Store for a discount of your choosing. + /// - keyIdentifier: The identifier of the public/private key pair agreed upon + /// with the App Store when the keys were generated. + /// - nonce: One-time use random entropy-adding value for security. + /// - signature: The cryptographic signature generated by your private key. + /// - timestamp: Timestamp of when the signature is created. + public init(identifier: String, keyIdentifier: String, nonce: UUID, signature: String, timestamp: Int) { + self.identifier = identifier + self.keyIdentifier = keyIdentifier + self.nonce = nonce + self.signature = signature + self.timestamp = timestamp + } + } +} + +// MARK: - Convenience Initializators + +extension PromotionalOffer.SignedData { + /// Creates a `SignedData` instance. + /// + /// - Parameter paymentDiscount: The signed discount applied to a payment. + convenience init(paymentDiscount: SKPaymentDiscount) { + self.init( + identifier: paymentDiscount.identifier, + keyIdentifier: paymentDiscount.keyIdentifier, + nonce: paymentDiscount.nonce, + signature: paymentDiscount.signature, + timestamp: paymentDiscount.timestamp.intValue + ) + } +} + +// MARK: - Helpers + +extension PromotionalOffer.SignedData { + var skPromotionalOffer: SKPaymentDiscount { + SKPaymentDiscount( + identifier: identifier, + keyIdentifier: keyIdentifier, + nonce: nonce, + signature: signature, + timestamp: .init(integerLiteral: timestamp) + ) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + var promotionalOffer: Product.PurchaseOption { + get throws { + guard let data = Data(base64Encoded: signature) else { + throw IAPError.failedToDecodeSignature(signature: signature) + } + + return .promotionalOffer( + offerID: identifier, + keyID: keyIdentifier, + nonce: nonce, + signature: data, + timestamp: timestamp + ) + } + } +} diff --git a/Sources/Flare/Classes/Models/StoreProduct.swift b/Sources/Flare/Classes/Models/StoreProduct.swift index 06255b5a2..bfbb34666 100644 --- a/Sources/Flare/Classes/Models/StoreProduct.swift +++ b/Sources/Flare/Classes/Models/StoreProduct.swift @@ -50,39 +50,51 @@ public extension StoreProduct { // MARK: ISKProduct extension StoreProduct: ISKProduct { - var localizedDescription: String { + public var localizedDescription: String { product.localizedDescription } - var localizedTitle: String { + public var localizedTitle: String { product.localizedTitle } - var currencyCode: String? { + public var currencyCode: String? { product.currencyCode } - var price: Decimal { + public var price: Decimal { product.price } - var localizedPriceString: String? { + public var localizedPriceString: String? { product.localizedPriceString } - var productIdentifier: String { + public var productIdentifier: String { product.productIdentifier } - var productType: ProductType? { + public var productType: ProductType? { product.productType } - var productCategory: ProductCategory? { + public var productCategory: ProductCategory? { product.productCategory } - var subscriptionPeriod: SubscriptionPeriod? { + public var subscriptionPeriod: SubscriptionPeriod? { product.subscriptionPeriod } + + public var introductoryDiscount: StoreProductDiscount? { + product.introductoryDiscount + } + + public var discounts: [StoreProductDiscount] { + product.discounts + } + + public var subscriptionGroupIdentifier: String? { + product.subscriptionGroupIdentifier + } } diff --git a/Sources/Flare/Classes/Models/StoreProductDiscount.swift b/Sources/Flare/Classes/Models/StoreProductDiscount.swift new file mode 100644 index 000000000..871755058 --- /dev/null +++ b/Sources/Flare/Classes/Models/StoreProductDiscount.swift @@ -0,0 +1,82 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - StoreProductDiscount + +/// The details of an introductory offer or a promotional offer for an auto-renewable subscription. +public final class StoreProductDiscount { + // MARK: Properties + + /// The details of an introductory offer or a promotional offer for an auto-renewable subscription. + private let discount: IStoreProductDiscount + + // MARK: Initialization + + /// Creates a `StoreProductDiscount` instance. + /// + /// - Parameter discount: The details of an introductory offer or a promotional offer for an auto-renewable subscription. + init(discount: IStoreProductDiscount) { + self.discount = discount + } +} + +// MARK: - Convenience Initializators + +public extension StoreProductDiscount { + /// Creates a new `StoreProductDiscount` instance. + /// + /// - Parameter skProductDiscount: The details of an introductory offer or a promotional + /// offer for an auto-renewable subscription. + convenience init?(skProductDiscount: SKProductDiscount) { + guard let discount = SK1StoreProductDiscount(productDiscount: skProductDiscount) else { return nil } + self.init(discount: discount) + } + + /// Creates a new `StoreProductDiscount` instance. + /// + /// - Parameters: + /// - subscriptionOffer: Information about a subscription offer that you configure in App Store Connect. + /// - currencyCode: The currency code for the discount amount. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + convenience init?(discount: StoreKit.Product.SubscriptionOffer, currencyCode: String?) { + guard let discount = SK2StoreProductDiscount(subscriptionOffer: discount, currencyCode: currencyCode) else { return nil } + self.init(discount: discount) + } +} + +// MARK: IStoreProductDiscount + +extension StoreProductDiscount: IStoreProductDiscount { + public var offerIdentifier: String? { + discount.offerIdentifier + } + + public var currencyCode: String? { + discount.currencyCode + } + + public var price: Decimal { + discount.price + } + + public var paymentMode: PaymentMode { + discount.paymentMode + } + + public var subscriptionPeriod: SubscriptionPeriod { + discount.subscriptionPeriod + } + + public var numberOfPeriods: Int { + discount.numberOfPeriods + } + + public var type: DiscountType { + discount.type + } +} diff --git a/Sources/Flare/Classes/Models/SubscriptionEligibility.swift b/Sources/Flare/Classes/Models/SubscriptionEligibility.swift new file mode 100644 index 000000000..362884ace --- /dev/null +++ b/Sources/Flare/Classes/Models/SubscriptionEligibility.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// Enumeration defining the eligibility status for a subscription +public enum SubscriptionEligibility: Int, Sendable { + // Represents that the subscription is eligible for an offer + case eligible + + // Represents that the subscription is not eligible for an offer + case nonEligible + + // Represents that there is no offer available for the subscription + case noOffer +} diff --git a/Sources/Flare/Classes/Models/SubscriptionPeriod.swift b/Sources/Flare/Classes/Models/SubscriptionPeriod.swift index 9bf8a2f52..3a8ef32cc 100644 --- a/Sources/Flare/Classes/Models/SubscriptionPeriod.swift +++ b/Sources/Flare/Classes/Models/SubscriptionPeriod.swift @@ -9,10 +9,10 @@ import StoreKit // MARK: - SubscriptionPeriod /// A class representing a subscription period with a specific value and unit. -public final class SubscriptionPeriod: NSObject { +public final class SubscriptionPeriod: NSObject, Sendable { // MARK: Types - public enum Unit: Int { + public enum Unit: Int, Sendable { /// A subscription period unit of a day. case day = 0 /// A subscription period unit of a week. @@ -41,6 +41,19 @@ public final class SubscriptionPeriod: NSObject { self.value = value self.unit = unit } + + override public func isEqual(_ object: Any?) -> Bool { + guard let other = object as? SubscriptionPeriod else { return false } + return value == other.value && unit == other.unit + } + + override public var hash: Int { + var hasher = Hasher() + hasher.combine(value) + hasher.combine(unit) + + return hasher.finalize() + } } // MARK: - Helpers @@ -66,7 +79,11 @@ extension SubscriptionPeriod { // MARK: - Extensions private extension SubscriptionPeriod.Unit { - @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + /// Creates a ``SubscriptionPeriod.Unit`` instance. + /// + /// - Parameter unit: Values representing the duration of an interval, from a day up to a year. + /// + /// - Returns: A subscription unit. static func from(unit: SKProduct.PeriodUnit) -> Self? { switch unit { case .day: @@ -82,6 +99,11 @@ private extension SubscriptionPeriod.Unit { } } + /// Creates a ``SubscriptionPeriod.Unit`` instance. + /// + /// - Parameter unit: Units of time that describe subscription periods. + /// + /// - Returns: A subscription unit. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8, *) static func from(unit: StoreKit.Product.SubscriptionPeriod.Unit) -> Self? { switch unit { diff --git a/Sources/Flare/Classes/Providers/CacheProvider/CacheProvider.swift b/Sources/Flare/Classes/Providers/CacheProvider/CacheProvider.swift new file mode 100644 index 000000000..a28ef627f --- /dev/null +++ b/Sources/Flare/Classes/Providers/CacheProvider/CacheProvider.swift @@ -0,0 +1,37 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - CacheProvider + +/// A class provides caching functionality. +final class CacheProvider { + // MARK: Properties + + /// The user defaults. + private let userDefaults: IUserDefaults + + // MARK: Initialization + + /// Creates a `CacheProvider` instance. + /// + /// - Parameter userDefaults: The user defaults. + init(userDefaults: IUserDefaults = UserDefaults.standard) { + self.userDefaults = userDefaults + } +} + +// MARK: ICacheProvider + +extension CacheProvider: ICacheProvider { + func read(key: String) -> T? { + userDefaults.get(key: key) + } + + func write(key: String, value: T) { + userDefaults.set(key: key, codable: value) + } +} diff --git a/Sources/Flare/Classes/Providers/CacheProvider/ICacheProvider.swift b/Sources/Flare/Classes/Providers/CacheProvider/ICacheProvider.swift new file mode 100644 index 000000000..9c5480dec --- /dev/null +++ b/Sources/Flare/Classes/Providers/CacheProvider/ICacheProvider.swift @@ -0,0 +1,23 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Type for a cache provider that supports reading and writing Codable values. +protocol ICacheProvider { + /// Reads a Codable value from the cache using the specified key. + /// + /// - Parameters: + /// - key: The key associated with the value in the cache. + /// - Returns: The Codable value associated with the key, or nil if not found. + func read(key: String) -> T? + + /// Writes a Codable value to the cache using the specified key. + /// + /// - Parameters: + /// - key: The key to associate with the value in the cache. + /// - value: The Codable value to be stored in the cache. + func write(key: String, value: T) +} diff --git a/Sources/Flare/Classes/Providers/ConfigurationProvider/ConfigurationProvider.swift b/Sources/Flare/Classes/Providers/ConfigurationProvider/ConfigurationProvider.swift new file mode 100644 index 000000000..08d3e7361 --- /dev/null +++ b/Sources/Flare/Classes/Providers/ConfigurationProvider/ConfigurationProvider.swift @@ -0,0 +1,44 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - ConfigurationProvider + +/// A class responsible for providing configuration settings, utilizing a cache provider. +final class ConfigurationProvider { + // MARK: Properties + + /// The cache provider used to store and retrieve configuration settings. + private let cacheProvider: ICacheProvider + + // MARK: Initialization + + /// Initializes a ConfigurationProvider with a specified cache provider. + /// + /// - Parameter cacheProvider: The cache provider to use. Defaults to an instance of + /// `CacheProvider` with standard UserDefaults. + init(cacheProvider: ICacheProvider = CacheProvider(userDefaults: UserDefaults.standard)) { + self.cacheProvider = cacheProvider + } +} + +// MARK: IConfigurationProvider + +extension ConfigurationProvider: IConfigurationProvider { + var applicationUsername: String? { + cacheProvider.read(key: .applicationUsername) + } + + func configure(with configuration: Configuration) { + cacheProvider.write(key: .applicationUsername, value: configuration.applicationUsername) + } +} + +// MARK: - Constants + +private extension String { + static let applicationUsername = "flare.configuration.application_username" +} diff --git a/Sources/Flare/Classes/Providers/ConfigurationProvider/IConfigurationProvider.swift b/Sources/Flare/Classes/Providers/ConfigurationProvider/IConfigurationProvider.swift new file mode 100644 index 000000000..cae9f7b26 --- /dev/null +++ b/Sources/Flare/Classes/Providers/ConfigurationProvider/IConfigurationProvider.swift @@ -0,0 +1,17 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Type for providing configuration settings to an application. +protocol IConfigurationProvider { + /// The application username. + var applicationUsername: String? { get } + + /// Configures the provider with the specified configuration settings. + /// + /// - Parameter configuration: The configuration settings to apply. + func configure(with configuration: Configuration) +} diff --git a/Sources/Flare/Classes/Providers/EligibilityProvider/EligibilityProvider.swift b/Sources/Flare/Classes/Providers/EligibilityProvider/EligibilityProvider.swift new file mode 100644 index 000000000..ddaa1be78 --- /dev/null +++ b/Sources/Flare/Classes/Providers/EligibilityProvider/EligibilityProvider.swift @@ -0,0 +1,33 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - EligibilityProvider + +/// A class that provides eligibility checking functionality. +final class EligibilityProvider {} + +// MARK: IEligibilityProvider + +extension EligibilityProvider: IEligibilityProvider { + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func checkEligibility(products: [StoreProduct]) async throws -> [String: SubscriptionEligibility] { + let underlyingProducts = products.compactMap { $0.underlyingProduct as? SK2StoreProduct } + + var result: [String: SubscriptionEligibility] = [:] + + for product in underlyingProducts { + if let subscription = product.product.subscription, subscription.introductoryOffer != nil { + let isEligible = await subscription.isEligibleForIntroOffer + result[product.productIdentifier] = isEligible ? .eligible : .nonEligible + } else { + result[product.productIdentifier] = .noOffer + } + } + + return result + } +} diff --git a/Sources/Flare/Classes/Providers/EligibilityProvider/IEligibilityProvider.swift b/Sources/Flare/Classes/Providers/EligibilityProvider/IEligibilityProvider.swift new file mode 100644 index 000000000..40ad67997 --- /dev/null +++ b/Sources/Flare/Classes/Providers/EligibilityProvider/IEligibilityProvider.swift @@ -0,0 +1,17 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Type that provides eligibility checking functionality. +protocol IEligibilityProvider { + /// Checks whether products are eligible for promotional offers + /// + /// - Parameter products: The products to be checked. + /// + /// - Returns: An array that contains information about the eligibility of products. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func checkEligibility(products: [StoreProduct]) async throws -> [String: SubscriptionEligibility] +} diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift index f2ed8c0a0..8850f3a19 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import StoreKit @@ -19,6 +19,10 @@ final class IAPProvider: IIAPProvider { private let receiptRefreshProvider: IReceiptRefreshProvider /// The provider is responsible for refunding purchases private let refundProvider: IRefundProvider + /// The provider is responsible for eligibility checking. + private let eligibilityProvider: IEligibilityProvider + /// The provider is tasked with handling code redemption. + private let redeemCodeProvider: IRedeemCodeProvider // MARK: Initialization @@ -27,23 +31,27 @@ final class IAPProvider: IIAPProvider { /// - Parameters: /// - paymentQueue: The queue of payment transactions to be processed by the App Store. /// - productProvider: The provider is responsible for fetching StoreKit products. - /// - purchaseProvider: + /// - purchaseProvider: The provider is respinsible for purchasing StoreKit product. /// - receiptRefreshProvider: The provider is responsible for refreshing receipts. /// - refundProvider: The provider is responsible for refunding purchases. + /// - eligibilityProvider: The provider is responsible for eligibility checking. + /// - redeemCodeProvider: The provider is tasked with handling code redemption. init( - paymentQueue: PaymentQueue = SKPaymentQueue.default(), - productProvider: IProductProvider = ProductProvider(), - purchaseProvider: IPurchaseProvider = PurchaseProvider(), - receiptRefreshProvider: IReceiptRefreshProvider = ReceiptRefreshProvider(), - refundProvider: IRefundProvider = RefundProvider( - systemInfoProvider: SystemInfoProvider() - ) + paymentQueue: PaymentQueue, + productProvider: IProductProvider, + purchaseProvider: IPurchaseProvider, + receiptRefreshProvider: IReceiptRefreshProvider, + refundProvider: IRefundProvider, + eligibilityProvider: IEligibilityProvider, + redeemCodeProvider: IRedeemCodeProvider ) { self.paymentQueue = paymentQueue self.productProvider = productProvider self.purchaseProvider = purchaseProvider self.receiptRefreshProvider = receiptRefreshProvider self.refundProvider = refundProvider + self.eligibilityProvider = eligibilityProvider + self.redeemCodeProvider = redeemCodeProvider } // MARK: Internal @@ -55,7 +63,7 @@ final class IAPProvider: IIAPProvider { func fetch(productIDs: Set, completion: @escaping Closure>) { if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { AsyncHandler.call( - completion: { [weak self] result in + completion: { [weak self] (result: Result<[SK2StoreProduct], Error>) in self?.handleFetchResult(result: result, completion) }, asyncMethod: { @@ -80,8 +88,12 @@ final class IAPProvider: IIAPProvider { } } - func purchase(product: StoreProduct, completion: @escaping Closure>) { - purchaseProvider.purchase(product: product) { result in + func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: @escaping Closure> + ) { + purchaseProvider.purchase(product: product, promotionalOffer: promotionalOffer) { result in switch result { case let .success(transaction): completion(.success(transaction)) @@ -91,9 +103,9 @@ final class IAPProvider: IIAPProvider { } } - func purchase(product: StoreProduct) async throws -> StoreTransaction { + func purchase(product: StoreProduct, promotionalOffer: PromotionalOffer?) async throws -> StoreTransaction { try await withCheckedThrowingContinuation { continuation in - self.purchase(product: product) { result in + self.purchase(product: product, promotionalOffer: promotionalOffer) { result in continuation.resume(with: result) } } @@ -103,15 +115,20 @@ final class IAPProvider: IIAPProvider { func purchase( product: StoreProduct, options: Set, + promotionalOffer: PromotionalOffer?, completion: @escaping SendableClosure> ) { - purchaseProvider.purchase(product: product, options: options, completion: completion) + purchaseProvider.purchase(product: product, options: options, promotionalOffer: promotionalOffer, completion: completion) } @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func purchase(product: StoreProduct, options: Set) async throws -> StoreTransaction { + func purchase( + product: StoreProduct, + options: Set, + promotionalOffer: PromotionalOffer? + ) async throws -> StoreTransaction { try await withCheckedThrowingContinuation { continuation in - purchase(product: product, options: options) { result in + purchase(product: product, options: options, promotionalOffer: promotionalOffer) { result in continuation.resume(with: result) } } @@ -152,6 +169,12 @@ final class IAPProvider: IIAPProvider { purchaseProvider.removeTransactionObserver() } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func checkEligibility(productIDs: Set) async throws -> [String: SubscriptionEligibility] { + let products = try await fetch(productIDs: productIDs) + return try await eligibilityProvider.checkEligibility(products: products) + } + #if os(iOS) || VISION_OS @available(iOS 15.0, *) @available(macOS, unavailable) @@ -160,6 +183,22 @@ final class IAPProvider: IIAPProvider { func beginRefundRequest(productID: String) async throws -> RefundRequestStatus { try await refundProvider.beginRefundRequest(productID: productID) } + + @available(iOS 14.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func presentCodeRedemptionSheet() { + paymentQueue.presentCodeRedemptionSheet() + } + + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func presentOfferCodeRedeemSheet() async throws { + try await redeemCodeProvider.presentOfferCodeRedeemSheet() + } #endif // MARK: Private diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift index ac7cf0bac..3e8899936 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift @@ -1,10 +1,12 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import StoreKit +// MARK: - IIAPProvider + /// Type that provides in-app purchase functionality. public protocol IIAPProvider { /// False if this device is not able or allowed to make payments @@ -34,8 +36,13 @@ public protocol IIAPProvider { /// /// - Parameters: /// - product: The product to be purchased. + /// - promotionalOffer: The promotional offer. /// - completion: The closure to be executed once the purchase is complete. - func purchase(product: StoreProduct, completion: @escaping Closure>) + func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: @escaping Closure> + ) /// Purchases a product. /// @@ -43,12 +50,14 @@ public protocol IIAPProvider { /// If the user can't make a payment, the method returns an error /// with the type `IAPError.paymentNotAllowed`. /// - /// - Parameter product: The product to be purchased. + /// - Parameters: + /// - product: The product to be purchased. + /// - promotionalOffer: The promotional offer. /// /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. /// /// - Returns: A payment transaction. - func purchase(product: StoreProduct) async throws -> StoreTransaction + func purchase(product: StoreProduct, promotionalOffer: PromotionalOffer?) async throws -> StoreTransaction /// Purchases a product with a given ID. /// @@ -59,6 +68,7 @@ public protocol IIAPProvider { /// - Parameters: /// - product: The product to be purchased. /// - options: The optional settings for a product purchase. + /// - promotionalOffer: The promotional offer. /// - completion: The closure to be executed once the purchase is complete. /// /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. @@ -68,6 +78,7 @@ public protocol IIAPProvider { func purchase( product: StoreProduct, options: Set, + promotionalOffer: PromotionalOffer?, completion: @escaping SendableClosure> ) @@ -80,12 +91,17 @@ public protocol IIAPProvider { /// - Parameters: /// - product: The product to be purchased. /// - options: The optional settings for a product purchase. + /// - promotionalOffer: The promotional offer. /// /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. /// /// - Returns: A payment transaction. @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func purchase(product: StoreProduct, options: Set) async throws -> StoreTransaction + func purchase( + product: StoreProduct, + options: Set, + promotionalOffer: PromotionalOffer? + ) async throws -> StoreTransaction /// Refreshes the receipt, representing the user's transactions with your app. /// @@ -119,6 +135,14 @@ public protocol IIAPProvider { /// - Note: This may require that the user authenticate. func removeTransactionObserver() + /// Checks whether products are eligible for promotional offers + /// + /// - Parameter productIDs: The list of product identifiers for which you wish to check eligibility. + /// + /// - Returns: An array that contains information about the eligibility of products. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func checkEligibility(productIDs: Set) async throws -> [String: SubscriptionEligibility] + #if os(iOS) || VISION_OS /// Present the refund request sheet for the specified transaction in a window scene. /// @@ -130,5 +154,98 @@ public protocol IIAPProvider { @available(watchOS, unavailable) @available(tvOS, unavailable) func beginRefundRequest(productID: String) async throws -> RefundRequestStatus + + /// Displays a sheet that enables users to redeem subscription offer codes that you configure in App Store Connect. + @available(iOS 14.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func presentCodeRedemptionSheet() + + /// Displays a sheet in the window scene that enables users to redeem + /// a subscription offer code that you configure in App Store + /// Connect. + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func presentOfferCodeRedeemSheet() async throws #endif } + +extension IIAPProvider { + /// Performs a purchase of a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - completion: The closure to be executed once the purchase is complete. + func purchase( + product: StoreProduct, + completion: @escaping Closure> + ) { + purchase(product: product, promotionalOffer: nil, completion: completion) + } + + /// Purchases a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameter product: The product to be purchased. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + func purchase(product: StoreProduct) async throws -> StoreTransaction { + try await purchase(product: product, promotionalOffer: nil) + } + + /// Purchases a product with a given ID. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// - completion: The closure to be executed once the purchase is complete. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + completion: @escaping SendableClosure> + ) { + purchase(product: product, options: options, promotionalOffer: nil, completion: completion) + } + + /// Purchases a product with a given ID. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set + ) async throws -> StoreTransaction { + try await purchase(product: product, options: options, promotionalOffer: nil) + } +} diff --git a/Sources/Flare/Classes/Providers/PaymentProvider/PaymentProvider.swift b/Sources/Flare/Classes/Providers/PaymentProvider/PaymentProvider.swift index 3261143d0..be3f107a6 100644 --- a/Sources/Flare/Classes/Providers/PaymentProvider/PaymentProvider.swift +++ b/Sources/Flare/Classes/Providers/PaymentProvider/PaymentProvider.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import Concurrency @@ -36,8 +36,8 @@ final class PaymentProvider: NSObject { /// - paymentQueue: The queue of payment transactions to be processed by the App Store. /// - dispatchQueueFactory: The dispatch queue factory. init( - paymentQueue: PaymentQueue = SKPaymentQueue.default(), - dispatchQueueFactory: IDispatchQueueFactory = DispatchQueueFactory() + paymentQueue: PaymentQueue, + dispatchQueueFactory: IDispatchQueueFactory ) { self.paymentQueue = paymentQueue self.dispatchQueueFactory = dispatchQueueFactory diff --git a/Sources/Flare/Classes/Providers/ProductProvider/ProductProvider.swift b/Sources/Flare/Classes/Providers/ProductProvider/ProductProvider.swift index 183741bbb..ce5cbdf30 100644 --- a/Sources/Flare/Classes/Providers/ProductProvider/ProductProvider.swift +++ b/Sources/Flare/Classes/Providers/ProductProvider/ProductProvider.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import Concurrency @@ -50,7 +50,7 @@ final class ProductProvider: NSObject, IProductProvider { // MARK: Private /// Dictionary to store request handlers with their corresponding request IDs. - private var handlers: [String: ProductsHandler] = [:] + private var handlers: [ProductsRequest: ProductsHandler] = [:] /// The dispatch queue factory for handling concurrent tasks. private let dispatchQueueFactory: IDispatchQueueFactory @@ -77,7 +77,7 @@ final class ProductProvider: NSObject, IProductProvider { /// - completion: A closure to be called upon completion with the fetched products. private func fetch(request: SKProductsRequest, completion: @escaping ProductsHandler) { dispatchQueue.async { - self.handlers[request.id] = completion + self.handlers[request.request] = completion self.dispatchQueueFactory.main().async { request.start() } @@ -90,7 +90,8 @@ final class ProductProvider: NSObject, IProductProvider { extension ProductProvider: SKProductsRequestDelegate { func request(_ request: SKRequest, didFailWithError error: Error) { dispatchQueue.async { - let handler = self.handlers.removeValue(forKey: request.id) + let handler = self.handlers.removeValue(forKey: request.request) + self.dispatchQueueFactory.main().async { handler?(.failure(IAPError(error: error))) } @@ -99,7 +100,7 @@ extension ProductProvider: SKProductsRequestDelegate { func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { dispatchQueue.async { - let handler = self.handlers.removeValue(forKey: request.id) + let handler = self.handlers.removeValue(forKey: request.request) guard response.invalidProductIdentifiers.isEmpty else { self.dispatchQueueFactory.main().async { @@ -114,3 +115,11 @@ extension ProductProvider: SKProductsRequestDelegate { } } } + +// MARK: - Helpers + +private extension SKRequest { + var request: ProductsRequest { + ProductsRequest(self) + } +} diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift index a02d3fad4..2fb931923 100644 --- a/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import Foundation @@ -35,19 +35,55 @@ protocol IPurchaseProvider { /// /// - Parameters: /// - product: The product to be purchased. + /// - promotionalOffer: The promotional offer. /// - completion: The closure to be executed once the purchase is complete. - func purchase(product: StoreProduct, completion: @escaping PurchaseCompletionHandler) + func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: @escaping PurchaseCompletionHandler + ) /// Purchases a product. /// /// - Parameters: /// - product: The product to be purchased. /// - options: The optional settings for a product purchase. + /// - promotionalOffer: The promotional offer. /// - completion: The closure to be executed once the purchase is complete. @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func purchase( product: StoreProduct, options: Set, + promotionalOffer: PromotionalOffer?, completion: @escaping PurchaseCompletionHandler ) } + +extension IPurchaseProvider { + /// Purchases a product. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - completion: The closure to be executed once the purchase is complete. + func purchase( + product: StoreProduct, + completion: @escaping PurchaseCompletionHandler + ) { + purchase(product: product, promotionalOffer: nil, completion: completion) + } + + /// Purchases a product. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// - completion: The closure to be executed once the purchase is complete. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + completion: @escaping PurchaseCompletionHandler + ) { + purchase(product: product, options: options, promotionalOffer: nil, completion: completion) + } +} diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift index ce26e3de3..ae5703cad 100644 --- a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift @@ -15,6 +15,8 @@ final class PurchaseProvider { private let paymentProvider: IPaymentProvider /// The transaction listener. private let transactionListener: ITransactionListener? + /// The configuration provider. + private let configurationProvider: IConfigurationProvider // MARK: Initialization @@ -23,11 +25,14 @@ final class PurchaseProvider { /// - Parameters: /// - paymentProvider: The provider is responsible for purchasing products. /// - transactionListener: The transaction listener. + /// - configurationProvider: The configuration provider. init( - paymentProvider: IPaymentProvider = PaymentProvider(), - transactionListener: ITransactionListener? = nil + paymentProvider: IPaymentProvider, + transactionListener: ITransactionListener? = nil, + configurationProvider: IConfigurationProvider ) { self.paymentProvider = paymentProvider + self.configurationProvider = configurationProvider if let transactionListener = transactionListener { self.transactionListener = transactionListener @@ -42,9 +47,12 @@ final class PurchaseProvider { private func purchase( sk1StoreProduct: SK1StoreProduct, + promotionalOffer: PromotionalOffer?, completion: @escaping @MainActor (Result) -> Void ) { - let payment = SKPayment(product: sk1StoreProduct.product) + let payment = SKMutablePayment(product: sk1StoreProduct.product) + payment.applicationUsername = configurationProvider.applicationUsername + payment.paymentDiscount = promotionalOffer?.signedData.skPromotionalOffer paymentProvider.add(payment: payment) { _, result in Task { switch result { @@ -61,9 +69,10 @@ final class PurchaseProvider { private func purchase( sk2StoreProduct: SK2StoreProduct, options: Set? = nil, + promotionalOffer: PromotionalOffer?, completion: @escaping @MainActor (Result) -> Void ) { - AsyncHandler.call(completion: { result in + AsyncHandler.call(completion: { (result: Result) in Task { switch result { case let .success(result): @@ -77,21 +86,39 @@ final class PurchaseProvider { } } }, asyncMethod: { - try await sk2StoreProduct.product.purchase(options: options ?? []) + var options: Set = options ?? [] + try self.configure(options: &options, promotionalOffer: promotionalOffer) + return try await sk2StoreProduct.product.purchase(options: options) }) } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + private func configure(options: inout Set, promotionalOffer: PromotionalOffer?) throws { + if let promotionalOffer { + try options.insert(promotionalOffer.signedData.promotionalOffer) + } + + if let applicationUsername = configurationProvider.applicationUsername, let uuid = UUID(uuidString: applicationUsername) { + // If options contain an app account token, the next line of code doesn't affect it. + options.insert(.appAccountToken(uuid)) + } + } } // MARK: IPurchaseProvider extension PurchaseProvider: IPurchaseProvider { - func purchase(product: StoreProduct, completion: @escaping PurchaseCompletionHandler) { + func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: @escaping PurchaseCompletionHandler + ) { if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *), let sk2Product = product.underlyingProduct as? SK2StoreProduct { - self.purchase(sk2StoreProduct: sk2Product, completion: completion) + self.purchase(sk2StoreProduct: sk2Product, promotionalOffer: promotionalOffer, completion: completion) } else if let sk1Product = product.underlyingProduct as? SK1StoreProduct { - purchase(sk1StoreProduct: sk1Product, completion: completion) + purchase(sk1StoreProduct: sk1Product, promotionalOffer: promotionalOffer, completion: completion) } } @@ -99,10 +126,16 @@ extension PurchaseProvider: IPurchaseProvider { func purchase( product: StoreProduct, options: Set, + promotionalOffer: PromotionalOffer?, completion: @escaping PurchaseCompletionHandler ) { if let sk2Product = product.underlyingProduct as? SK2StoreProduct { - purchase(sk2StoreProduct: sk2Product, options: options, completion: completion) + purchase( + sk2StoreProduct: sk2Product, + options: options, + promotionalOffer: promotionalOffer, + completion: completion + ) } else { Task { await completion(.failure(.unknown)) diff --git a/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/ReceiptRefreshProvider.swift b/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/ReceiptRefreshProvider.swift index 5585f0b34..9bf336936 100644 --- a/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/ReceiptRefreshProvider.swift +++ b/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/ReceiptRefreshProvider.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import Concurrency @@ -38,10 +38,10 @@ final class ReceiptRefreshProvider: NSObject { /// - appStoreReceiptProvider: The type that retrieves the App Store receipt URL. /// - receiptRefreshRequestFactory: The receipt refresh request factory. init( - dispatchQueueFactory: IDispatchQueueFactory = DispatchQueueFactory(), + dispatchQueueFactory: IDispatchQueueFactory, fileManager: IFileManager = FileManager.default, appStoreReceiptProvider: IAppStoreReceiptProvider = Bundle.main, - receiptRefreshRequestFactory: IReceiptRefreshRequestFactory = ReceiptRefreshRequestFactory() + receiptRefreshRequestFactory: IReceiptRefreshRequestFactory ) { self.dispatchQueueFactory = dispatchQueueFactory self.fileManager = fileManager diff --git a/Sources/Flare/Classes/Providers/RedeemCodeProvider/IRedeemCodeProvider.swift b/Sources/Flare/Classes/Providers/RedeemCodeProvider/IRedeemCodeProvider.swift new file mode 100644 index 000000000..03981bec2 --- /dev/null +++ b/Sources/Flare/Classes/Providers/RedeemCodeProvider/IRedeemCodeProvider.swift @@ -0,0 +1,24 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Protocol defining the requirements for a redeem code provider. +protocol IRedeemCodeProvider { + #if os(iOS) || VISION_OS + /// Displays a sheet in the window scene that enables users to redeem + /// a subscription offer code configured in App Store Connect. + /// + /// - Important: This method is available starting from iOS 16.0. + /// - Note: This method is not available on macOS, watchOS, or tvOS. + /// + /// - Throws: An error if there is an issue with presenting the redeem code sheet. + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func presentOfferCodeRedeemSheet() async throws + #endif +} diff --git a/Sources/Flare/Classes/Providers/RedeemCodeProvider/RedeemCodeProvider.swift b/Sources/Flare/Classes/Providers/RedeemCodeProvider/RedeemCodeProvider.swift new file mode 100644 index 000000000..a4301be86 --- /dev/null +++ b/Sources/Flare/Classes/Providers/RedeemCodeProvider/RedeemCodeProvider.swift @@ -0,0 +1,43 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - RedeemCodeProvider + +/// A final class responsible for providing functionality related to redeeming offer codes. +final class RedeemCodeProvider { + // MARK: Properties + + /// An instance of a system information provider conforming to the `ISystemInfoProvider` + private let systemInfoProvider: ISystemInfoProvider + + // MARK: Initialization + + /// Initializes a `RedeemCodeProvider` instance with an optional system information provider. + /// + /// - Parameter systemInfoProvider: An instance of a system information provider. + /// Defaults to a new instance of `SystemInfoProvider` if not provided. + init(systemInfoProvider: ISystemInfoProvider = SystemInfoProvider()) { + self.systemInfoProvider = systemInfoProvider + } +} + +// MARK: IRedeemCodeProvider + +extension RedeemCodeProvider: IRedeemCodeProvider { + #if os(iOS) || VISION_OS + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @MainActor + func presentOfferCodeRedeemSheet() async throws { + let windowScene = try systemInfoProvider.currentScene + try await AppStore.presentOfferCodeRedeemSheet(in: windowScene) + } + #endif +} diff --git a/Sources/Flare/Flare.docc/Articles/perform-purchase.md b/Sources/Flare/Flare.docc/Articles/perform-purchase.md index 55d439082..65f127781 100644 --- a/Sources/Flare/Flare.docc/Articles/perform-purchase.md +++ b/Sources/Flare/Flare.docc/Articles/perform-purchase.md @@ -10,7 +10,7 @@ The transactions array will only be synchronized with the server while the queue ```swift // Adds transaction observer to the payment queue and handles payment transactions. -Flare.default.addTransactionObserver { result in +Flare.shared.addTransactionObserver { result in switch result { case let .success(transaction): debugPrint("A transaction was received: \(transaction)") @@ -22,7 +22,7 @@ Flare.default.addTransactionObserver { result in ```swift // Removes transaction observer from the payment queue. -Flare.default.removeTransactionObserver() +Flare.shared.removeTransactionObserver() ``` ## Getting Products @@ -32,7 +32,7 @@ The fetch method sends a request to the App Store, which retrieves the products > important: Before attempting to add a payment always check if the user can actually make payments. The Flare does it under the hood, if a user cannot make payments, you will get an ``IAPError`` with the value ``IAPError/paymentNotAllowed``. ```swift -Flare.default.fetch(productIDs: ["product_id"]) { result in +Flare.shared.fetch(productIDs: ["product_id"]) { result in switch result { case let .success(products): debugPrint("Fetched products: \(products)") @@ -46,7 +46,7 @@ Additionally, there are versions of both fetch that provide an `async` method, a ```swift do { - let products = try await Flare.default.fetch(productIDs: Set(arrayLiteral: ["product_id"])) + let products = try await Flare.shared.fetch(productIDs: Set(arrayLiteral: ["product_id"])) } catch { debugPrint("An error occurred while fetching products: \(error.localizedDescription)") } @@ -64,7 +64,7 @@ Flare provides a few methods to perform a purchase: The method accepts a product parameter which represents a product: ```swift -Flare.default.purchase(product: product) { result in +Flare.shared.purchase(product: product) { result in switch result { case let .success(transaction): debugPrint("A transaction was received: \(transaction)") @@ -77,7 +77,7 @@ Flare.default.purchase(product: product) { result in If your app has a deployment target higher than iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, you can pass a set of [`options`](https://developer.apple.com/documentation/storekit/product/purchaseoption) along with a purchase request. ```swift -let transaction = try await Flare.default.purchase(product: product, options: [.appAccountToken(UUID())]) +let transaction = try await Flare.shared.purchase(product: product, options: [.appAccountToken(UUID())]) ``` ## Finishing Transaction @@ -87,7 +87,7 @@ Finishing a transaction tells StoreKit that your app completed its workflow to m To finish the transaction, call the ``IFlare/finish(transaction:completion:)`` method. ```swift -Flare.default.finish(transaction: transaction, completion: nil) +Flare.shared.finish(transaction: transaction, completion: nil) ``` > important: Don’t call the ``IFlare/finish(transaction:completion:)`` method before the transaction is actually complete and attempt to use some other mechanism in your app to track the transaction as unfinished. StoreKit doesn’t function that way, and doing that prevents your app from downloading Apple-hosted content and can lead to other issues. diff --git a/Sources/Flare/Flare.docc/Articles/promotional-offers.md b/Sources/Flare/Flare.docc/Articles/promotional-offers.md new file mode 100644 index 000000000..bdb9015c1 --- /dev/null +++ b/Sources/Flare/Flare.docc/Articles/promotional-offers.md @@ -0,0 +1,86 @@ +# Promotional Offers + +Learn how to use promotional offers. + +## Overview + +[Promotional offers](https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/subscriptions_and_offers/implementing_promotional_offers_in_your_app) can be effective in winning back lapsed subscribers or retaining current subscribers. You can provide lapsed or current subscribers a limited-time offer of a discounted or free period of service for auto-renewable subscriptions on macOS, iOS, and tvOS. + +[Introductory offers](https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/subscriptions_and_offers/implementing_introductory_offers_in_your_app#2940726) can offer a discounted introductory price, including a free trial, to eligible users. You can make introductory offers to customers who haven’t previously received an introductory offer for the given product, or for any products in the same subscription group. + +> note: To implement the offers, first complete the setup on App Store Connect, including generating a private key. See [Setting up promotional offers](https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/subscriptions_and_offers/setting_up_promotional_offers) for more details. + +## Introductory Offers + +> important: Do not show a subscription offer to users if they are not eligible for it. It’s very important to check this beforehand. + +First, check if the user is eligible for an introductory offer. + +> tip For this purpose can be used ``IFlare/checkEligibility(productIDs:)`` method. This method requires iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0. Otherwise, see [Determine Eligibility](https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/subscriptions_and_offers/implementing_introductory_offers_in_your_app#2940726). + +```swift +func isEligibleForIntroductoryOffer(productID: String) async -> Bool { + let dict = await Flare.shared.checkEligibility(productIDs: [productID]) + return dict[productID] == .eligible +} +``` + +Second, proceed with the purchase as usual. See [Perform Purchase]() + +## Promotional Offers + +First, you need to fetch the signature from your server. See [Generation a signature for promotional offers](https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/subscriptions_and_offers/generating_a_signature_for_promotional_offers) for more information. + +Second, configure ``IFlare`` with a ``Configuration``. + +```swift +Flare.configure(configuration: Configuration(applicationUsername: "username")) +``` + +Third, request a signature from your server and prepare the discount offer. + +```swift +func prepareOffer(username: String, productID: String, offerID: String, completion: @escaping (PromotionalOffer.SignedData) -> Void) { + YourServer.fetchOfferDetails( + username: username, + productIdentifier: productID, + offerIdentifier: offerID, + completion: { (nonce: UUID, timestamp: NSNumber, keyIdentifier: String, signature: String) in + let signedData = PromotionalOffer.SignedData( + identifier: offerID, + keyIdentifier: keyIdentifier, + nonce: nonce, + signature: signature, + timestamp: timestamp + ) + + completion(signedData) + } +} +``` + +Fourth, complete the purchase with the promotional offer. + +```swift +func purchase(product: StoreProduct, discount: StoreProductDiscount, signedData: SignedData) { + let promotionalOffer = PromotionalOffer(discount: discount, signedData: signedData) + + Flare.default.purchase(product: product, promotionalOffer: promotionalOffer) { result in + switch result { + case let .success(transaction): + break + case let .failure(error): + break + } + } + + // Or using async/await + let transaction = Flare.shared.purchase(product: product, promotionalOffer: promotionalOffer) +} +``` + +Fifth, complete the transaction. + +```swift +Flare.default.finish(transaction: transaction) +``` diff --git a/Sources/Flare/Flare.docc/Articles/refund-purchase.md b/Sources/Flare/Flare.docc/Articles/refund-purchase.md index f4f551738..46ab24f6d 100644 --- a/Sources/Flare/Flare.docc/Articles/refund-purchase.md +++ b/Sources/Flare/Flare.docc/Articles/refund-purchase.md @@ -9,7 +9,7 @@ Starting with iOS 15, Flare now includes support for refunding purchases as part Flare suggest to use ``IFlare/beginRefundRequest(productID:)`` for refunding purchase. ```swift -let status = try await Flare.default.beginRefundRequest(productID: "product_id") +let status = try await Flare.shared.beginRefundRequest(productID: "product_id") ``` > important: If an issue occurs during the refund process, this method throws an ``IAPError/refund(error:)`` error. diff --git a/Sources/Flare/Flare.docc/Articles/restore-purchase.md b/Sources/Flare/Flare.docc/Articles/restore-purchase.md index 18dae9f76..1164954b4 100644 --- a/Sources/Flare/Flare.docc/Articles/restore-purchase.md +++ b/Sources/Flare/Flare.docc/Articles/restore-purchase.md @@ -17,7 +17,7 @@ Use this API to request a new app receipt from the App Store if the receipt is i > important: The receipt refresh request displays a system prompt that asks users to authenticate with their App Store credentials. For a better user experience, initiate the request after an explicit user action, like tapping or clicking a button. ```swift -Flare.default.receipt { result in +Flare.shared.receipt { result in switch result { case let .success(receipt): // Handle a receipt @@ -32,5 +32,5 @@ Flare.default.receipt { result in There is an ``IFlare/receipt()`` method for obtaining a receipt using async/await. ```swift -let receipt = try await Flare.default.receipt() +let receipt = try await Flare.shared.receipt() ``` diff --git a/Sources/Flare/Flare.docc/Flare.md b/Sources/Flare/Flare.docc/Flare.md index 0682d736d..847fb53fa 100644 --- a/Sources/Flare/Flare.docc/Flare.md +++ b/Sources/Flare/Flare.docc/Flare.md @@ -10,11 +10,11 @@ Flare provides a clear and convenient API for making in-app purchases. import Flare /// Fetch a product with the given id -guard let product = try await Flare.default.products(productIDs: ["product_identifier"]) else { return } +guard let product = try await Flare.shared.products(productIDs: ["product_identifier"]) else { return } /// Purchase a product -let transaction = try await Flare.default.purchase(product: product) +let transaction = try await Flare.shared.purchase(product: product) /// Finish a transaction -Flare.default.finish(transaction: transaction, completion: nil) +Flare.shared.finish(transaction: transaction, completion: nil) ``` Flare supports both StoreKit and StoreKit2; it decides which one to use under the hood based on the operating system version. Flare provides two ways to work with in-app purchases (IAP): it supports the traditional closure-based syntax and the modern async/await approach. @@ -23,7 +23,7 @@ Flare supports both StoreKit and StoreKit2; it decides which one to use under th import Flare /// Fetch a product with the given id -Flare.default.products(productIDs: ["product_identifier"]) { result in +Flare.shared.products(productIDs: ["product_identifier"]) { result in switch result { case let .success(products): // Purchase a product @@ -64,3 +64,4 @@ flare is available under the MIT license. See the LICENSE file for more info. - - - +- diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index 33cc4debc..6998e78bf 100644 --- a/Tests/FlareTests/UnitTests/FlareTests.swift +++ b/Tests/FlareTests/UnitTests/FlareTests.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // @testable import Flare @@ -12,7 +12,9 @@ import XCTest class FlareTests: XCTestCase { // MARK: - Properties + private var dependenciesMock: FlareDependenciesMock! private var iapProviderMock: IAPProviderMock! + private var configurationProviderMock: ConfigurationProviderMock! private var sut: Flare! @@ -21,10 +23,16 @@ class FlareTests: XCTestCase { override func setUp() { super.setUp() iapProviderMock = IAPProviderMock() - sut = Flare(iapProvider: iapProviderMock) + dependenciesMock = FlareDependenciesMock() + configurationProviderMock = ConfigurationProviderMock() + dependenciesMock.stubbedIapProvider = iapProviderMock + dependenciesMock.stubbedConfigurationProvider = configurationProviderMock + sut = Flare(dependencies: dependenciesMock) } override func tearDown() { + configurationProviderMock = nil + dependenciesMock = nil iapProviderMock = nil sut = nil super.tearDown() @@ -64,8 +72,8 @@ class FlareTests: XCTestCase { sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { _ in }) // then - XCTAssertTrue(iapProviderMock.invokedPurchase) - XCTAssertEqual(iapProviderMock.invokedPurchaseParameters?.product.productIdentifier, .productID) + XCTAssertTrue(iapProviderMock.invokedPurchaseWithPromotionalOffer) + XCTAssertEqual(iapProviderMock.invokedPurchaseWithPromotionalOfferParameters?.product.productIdentifier, .productID) } func test_thatFlareThrowsAnError_whenUserCannotMakePayments() { @@ -83,6 +91,7 @@ class FlareTests: XCTestCase { // given let paymentTransaction = StoreTransaction(storeTransaction: StoreTransactionStub()) iapProviderMock.stubbedCanMakePayments = true + iapProviderMock.stubbedPurchaseWithPromotionalOffer = .success(paymentTransaction) // when var transaction: IStoreTransaction? @@ -92,7 +101,7 @@ class FlareTests: XCTestCase { iapProviderMock.invokedPurchaseParameters?.completion(.success(paymentTransaction)) // then - XCTAssertTrue(iapProviderMock.invokedPurchase) + XCTAssertTrue(iapProviderMock.invokedPurchaseWithPromotionalOffer) XCTAssertEqual(transaction?.productIdentifier, paymentTransaction.productIdentifier) } @@ -100,6 +109,7 @@ class FlareTests: XCTestCase { // given let errorMock = IAPError.paymentNotAllowed iapProviderMock.stubbedCanMakePayments = true + iapProviderMock.stubbedPurchaseWithPromotionalOffer = .failure(errorMock) // when var error: IAPError? @@ -109,7 +119,7 @@ class FlareTests: XCTestCase { iapProviderMock.invokedPurchaseParameters?.completion(.failure(errorMock)) // then - XCTAssertTrue(iapProviderMock.invokedPurchase) + XCTAssertTrue(iapProviderMock.invokedPurchaseWithPromotionalOffer) XCTAssertEqual(error, errorMock) } @@ -131,13 +141,13 @@ class FlareTests: XCTestCase { let transactionMock = StoreTransaction(storeTransaction: StoreTransactionStub()) iapProviderMock.stubbedCanMakePayments = true - iapProviderMock.stubbedAsyncPurchase = transactionMock + iapProviderMock.stubbedPurchaseAsyncWithPromotionalOffer = transactionMock // when let transaction = await value(for: { try await sut.purchase(product: .fake(skProduct: .fake(id: .productID))) }) // then - XCTAssertTrue(iapProviderMock.invokedAsyncPurchase) + XCTAssertTrue(iapProviderMock.invokedPurchaseAsyncWithPromotionalOffer) XCTAssertEqual(transaction?.productIdentifier, transactionMock.productIdentifier) } @@ -213,6 +223,19 @@ class FlareTests: XCTestCase { // then XCTAssertTrue(iapProviderMock.invokedAddTransactionObserver) } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func test_thatFlareChecksEligibility() async throws { + // given + iapProviderMock.stubbedCheckEligibility = [.productID: .eligible] + + // when + let _ = try await sut.checkEligibility(productIDs: [.productID]) + + // then + XCTAssertEqual(iapProviderMock.invokedCheckEligibilityCount, 1) + XCTAssertEqual(iapProviderMock.invokedCheckEligibilityParameters?.productIDs, [.productID]) + } } // MARK: - Constants diff --git a/Tests/FlareTests/UnitTests/Models/PromotionalOfferTests.swift b/Tests/FlareTests/UnitTests/Models/PromotionalOfferTests.swift new file mode 100644 index 000000000..e172dc4ab --- /dev/null +++ b/Tests/FlareTests/UnitTests/Models/PromotionalOfferTests.swift @@ -0,0 +1,56 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit +import XCTest + +// MARK: - PromotionalOfferTests + +final class PromotionalOfferTests: XCTestCase { + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func test_purchaseOptions() throws { + let option = try PromotionalOffer.SignedData.randomOffer.promotionalOffer + let expected: Product.PurchaseOption = .promotionalOffer( + offerID: PromotionalOffer.SignedData.randomOffer.identifier, + keyID: PromotionalOffer.SignedData.randomOffer.keyIdentifier, + nonce: PromotionalOffer.SignedData.randomOffer.nonce, + signature: Data(), + timestamp: PromotionalOffer.SignedData.randomOffer.timestamp + ) + + XCTAssertEqual(expected, option) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func test_purchaseOptionWithInvalidSignatureThrows() throws { + do { + _ = try PromotionalOffer.SignedData.invalidOffer.promotionalOffer + } catch { + let error = try XCTUnwrap(error as? IAPError) + XCTAssertEqual(error, IAPError.failedToDecodeSignature(signature: PromotionalOffer.SignedData.invalidOffer.signature)) + } + } +} + +// MARK: - Constants + +private extension PromotionalOffer.SignedData { + static let randomOffer: PromotionalOffer.SignedData = .init( + identifier: "identifier \(Int.random(in: 0 ..< 1000))", + keyIdentifier: "key identifier \(Int.random(in: 0 ..< 1000))", + nonce: .init(), + signature: "signature \(Int.random(in: 0 ..< 1000))".asData.base64EncodedString(), + timestamp: Int.random(in: 0 ..< 1000) + ) + + static let invalidOffer: PromotionalOffer.SignedData = .init( + identifier: "identifier \(Int.random(in: 0 ..< 1000))", + keyIdentifier: "key identifier \(Int.random(in: 0 ..< 1000))", + nonce: .init(), + signature: "signature \(Int.random(in: 0 ..< 1000))", + timestamp: Int.random(in: 0 ..< 1000) + ) +} diff --git a/Tests/FlareTests/UnitTests/Providers/ConfigurationProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/ConfigurationProviderTests.swift new file mode 100644 index 000000000..634f230be --- /dev/null +++ b/Tests/FlareTests/UnitTests/Providers/ConfigurationProviderTests.swift @@ -0,0 +1,67 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import XCTest + +// MARK: - ConfigurationProviderTests + +final class ConfigurationProviderTests: XCTestCase { + // MARK: Properties + + private var cacheProviderMock: CacheProviderMock! + + private var sut: ConfigurationProvider! + + // MARK: Initialization + + override func setUp() { + super.setUp() + cacheProviderMock = CacheProviderMock() + sut = ConfigurationProvider( + cacheProvider: cacheProviderMock + ) + } + + override func tearDown() { + cacheProviderMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatCacheProviderReturnsApplicationUsername_whenUsernameExists() { + // given + cacheProviderMock.stubbedReadResult = String.applicationUsername + + // when + let applicationUsername = sut.applicationUsername + + // then + XCTAssertEqual(cacheProviderMock.invokedReadParameters?.key, .applicationUsernameKey) + XCTAssertEqual(applicationUsername, .applicationUsername) + } + + func test_thatCacheProviderConfigures() { + // given + let configurationFake = Configuration.fake() + + // when + sut.configure(with: configurationFake) + + // then + XCTAssertEqual(cacheProviderMock.invokedWriteParameters?.key, .applicationUsernameKey) + XCTAssertEqual(cacheProviderMock.invokedWriteParameters?.value as? String, configurationFake.applicationUsername) + } +} + +// MARK: - Constants + +private extension String { + static let applicationUsername = "application_username" + + static let applicationUsernameKey = "flare.configuration.application_username" +} diff --git a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift index 319d97f15..a9793760c 100644 --- a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // @testable import Flare @@ -34,7 +34,9 @@ class IAPProviderTests: XCTestCase { productProvider: productProviderMock, purchaseProvider: purchaseProvider, receiptRefreshProvider: receiptRefreshProviderMock, - refundProvider: refundProviderMock + refundProvider: refundProviderMock, + eligibilityProvider: EligibilityProviderMock(), + redeemCodeProvider: RedeemCodeProviderMock() ) } diff --git a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift index af4b7a49a..abc7c95e9 100644 --- a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // @testable import Flare @@ -25,7 +25,8 @@ final class PurchaseProviderTests: XCTestCase { paymentQueueMock = PaymentQueueMock() paymentProviderMock = PaymentProviderMock() sut = PurchaseProvider( - paymentProvider: paymentProviderMock + paymentProvider: paymentProviderMock, + configurationProvider: ConfigurationProviderMock() ) } diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Extensions/String+Data.swift b/Tests/FlareTests/UnitTests/TestHelpers/Extensions/String+Data.swift new file mode 100644 index 000000000..50643fa69 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Extensions/String+Data.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +extension String { + var asData: Data { + Data(utf8) + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Fakes/Configuration+Fake.swift b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/Configuration+Fake.swift new file mode 100644 index 000000000..7ffdd13d5 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/Configuration+Fake.swift @@ -0,0 +1,13 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +extension Configuration { + static func fake(applicationUsername: String = "username") -> Configuration { + Configuration(applicationUsername: applicationUsername) + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/CacheProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/CacheProviderMock.swift new file mode 100644 index 000000000..413504174 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/CacheProviderMock.swift @@ -0,0 +1,35 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +final class CacheProviderMock: ICacheProvider { + var invokedRead = false + var invokedReadCount = 0 + var invokedReadParameters: (key: String, Void)? + var invokedReadParametersList = [(key: String, Void)]() + var stubbedReadResult: Any! + + func read(key: String) -> T? { + invokedRead = true + invokedReadCount += 1 + invokedReadParameters = (key, ()) + invokedReadParametersList.append((key, ())) + return stubbedReadResult as? T + } + + var invokedWrite = false + var invokedWriteCount = 0 + var invokedWriteParameters: (key: String, value: Any)? + var invokedWriteParametersList = [(key: String, value: Any)]() + + func write(key: String, value: T) { + invokedWrite = true + invokedWriteCount += 1 + invokedWriteParameters = (key, value) + invokedWriteParametersList.append((key, value)) + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/CacheProviderTests.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/CacheProviderTests.swift new file mode 100644 index 000000000..55ae8c599 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/CacheProviderTests.swift @@ -0,0 +1,61 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import XCTest + +// MARK: - CacheProviderTests + +final class CacheProviderTests: XCTestCase { + // MARK: Properties + + private var userDefaultsMock: UserDefaultsMock! + + private var sut: CacheProvider! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + userDefaultsMock = UserDefaultsMock() + sut = CacheProvider(userDefaults: userDefaultsMock) + } + + override func tearDown() { + userDefaultsMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_write() { + // when + sut.write(key: .key, value: String.value) + + // then + XCTAssertEqual(userDefaultsMock.invokedSetParameters?.key, .key) + XCTAssertEqual(userDefaultsMock.invokedSetParameters?.codable as? String, String.value) + } + + func test_read() { + // given + userDefaultsMock.stubbedGetResult = String.value + + // when + let value: String? = sut.read(key: .key) + + // then + XCTAssertEqual(userDefaultsMock.invokedGetParameters?.key, .key) + XCTAssertEqual(value, String.value) + } +} + +// MARK: - Constants + +private extension String { + static let key = "key" + static let value = "value" +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ConfigurationProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ConfigurationProviderMock.swift new file mode 100644 index 000000000..0f460b5c0 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ConfigurationProviderMock.swift @@ -0,0 +1,31 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +final class ConfigurationProviderMock: IConfigurationProvider { + var invokedApplicationUsernameGetter = false + var invokedApplicationUsernameGetterCount = 0 + var stubbedApplicationUsername: String! + + var applicationUsername: String? { + invokedApplicationUsernameGetter = true + invokedApplicationUsernameGetterCount += 1 + return stubbedApplicationUsername + } + + var invokedConfigure = false + var invokedConfigureCount = 0 + var invokedConfigureParameters: (configuration: Configuration, Void)? + var invokedConfigureParametersList = [(configuration: Configuration, Void)]() + + func configure(with configuration: Configuration) { + invokedConfigure = true + invokedConfigureCount += 1 + invokedConfigureParameters = (configuration, ()) + invokedConfigureParametersList.append((configuration, ())) + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/EligibilityProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/EligibilityProviderMock.swift new file mode 100644 index 000000000..94578e24b --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/EligibilityProviderMock.swift @@ -0,0 +1,23 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +final class EligibilityProviderMock: IEligibilityProvider { + var invokedCheckEligibility = false + var invokedCheckEligibilityCount = 0 + var invokedCheckEligibilityParameters: (products: [StoreProduct], Void)? + var invokedCheckEligibilityParametersList = [(products: [StoreProduct], Void)]() + var stubbedCheckEligibility: [String: SubscriptionEligibility] = [:] + + func checkEligibility(products: [StoreProduct]) async throws -> [String: SubscriptionEligibility] { + invokedCheckEligibility = true + invokedCheckEligibilityCount += 1 + invokedCheckEligibilityParameters = (products, ()) + invokedCheckEligibilityParametersList.append((products, ())) + return stubbedCheckEligibility + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/FlareDependenciesMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/FlareDependenciesMock.swift new file mode 100644 index 000000000..ac2886dba --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/FlareDependenciesMock.swift @@ -0,0 +1,29 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +final class FlareDependenciesMock: IFlareDependencies { + var invokedIapProviderGetter = false + var invokedIapProviderGetterCount = 0 + var stubbedIapProvider: IIAPProvider! + + var iapProvider: IIAPProvider { + invokedIapProviderGetter = true + invokedIapProviderGetterCount += 1 + return stubbedIapProvider + } + + var invokedConfigurationProviderGetter = false + var invokedConfigurationProviderGetterCount = 0 + var stubbedConfigurationProvider: IConfigurationProvider! + + var configurationProvider: IConfigurationProvider { + invokedConfigurationProviderGetter = true + invokedConfigurationProviderGetterCount += 1 + return stubbedConfigurationProvider + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift index e6a194485..1d05ea36c 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // @testable import Flare @@ -190,4 +190,98 @@ final class IAPProviderMock: IIAPProvider { invokedAsyncPurchaseWithOptionsParametersList.append((product, options)) return stubbedAsyncPurchaseWithOptions } + + var invokedPurchaseWithPromotionalOffer = false + var invokedPurchaseWithPromotionalOfferCount = 0 + var invokedPurchaseWithPromotionalOfferParameters: (product: StoreProduct, promotionalOffer: PromotionalOffer?)? + var invokedPurchaseWithPromotionalOfferParametersList = [(product: StoreProduct, VpromotionalOffer: PromotionalOffer?)]() + var stubbedPurchaseWithPromotionalOffer: Result? + + func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: @escaping Closure> + ) { + invokedPurchaseWithPromotionalOffer = true + invokedPurchaseWithPromotionalOfferCount += 1 + invokedPurchaseWithPromotionalOfferParameters = (product, promotionalOffer) + invokedPurchaseWithPromotionalOfferParametersList.append((product, promotionalOffer)) + + if let result = stubbedPurchaseWithPromotionalOffer { + completion(result) + } + } + + var invokedPurchaseAsyncWithPromotionalOffer = false + var invokedPurchaseAsyncWithPromotionalOfferCount = 0 + var invokedPurchaseAsyncWithPromotionalOfferParameters: (product: StoreProduct, promotionalOffer: PromotionalOffer?)? + var invokedPurchaseAsyncWithPromotionalOfferParametersList = [(product: StoreProduct, VpromotionalOffer: PromotionalOffer?)]() + var stubbedPurchaseAsyncWithPromotionalOffer: StoreTransaction! + + func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer? + ) async throws -> StoreTransaction { + invokedPurchaseAsyncWithPromotionalOffer = true + invokedPurchaseAsyncWithPromotionalOfferCount += 1 + invokedPurchaseAsyncWithPromotionalOfferParameters = (product, promotionalOffer) + invokedPurchaseAsyncWithPromotionalOfferParametersList.append((product, promotionalOffer)) + return stubbedPurchaseAsyncWithPromotionalOffer + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product _: StoreProduct, + options _: Set, + promotionalOffer _: PromotionalOffer?, + completion _: @escaping SendableClosure> + ) {} + + var invokedPurchaseWithOptionsAndPromotionalOffer = false + var invokedPurchaseWithOptionsAndPromotionalOfferCount = 0 + var stubbedPurchaseWithOptionsAndPromotionalOffer: StoreTransaction! + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product _: StoreProduct, + options _: Set, + promotionalOffer _: PromotionalOffer? + ) async throws -> StoreTransaction { + invokedPurchaseWithOptionsAndPromotionalOffer = true + invokedPurchaseWithOptionsAndPromotionalOfferCount += 1 + return stubbedPurchaseWithOptionsAndPromotionalOffer + } + + var invokedCheckEligibility = false + var invokedCheckEligibilityCount = 0 + var invokedCheckEligibilityParameters: (productIDs: Set, Void)? + var invokedCheckEligibilityParametersList = [(productIDs: Set, Void)]() + var stubbedCheckEligibility: [String: SubscriptionEligibility] = [:] + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func checkEligibility(productIDs: Set) async throws -> [String: SubscriptionEligibility] { + invokedCheckEligibility = true + invokedCheckEligibilityCount += 1 + invokedCheckEligibilityParameters = (productIDs, ()) + invokedCheckEligibilityParametersList = [(productIDs, ())] + return stubbedCheckEligibility + } + + var invokedPresentCodeRedemptionSheet = false + var invokedPresentCodeRedemptionSheetCount = 0 + + @available(iOS 14.0, *) + func presentCodeRedemptionSheet() { + invokedPresentCodeRedemptionSheet = true + invokedPresentCodeRedemptionSheetCount += 1 + } + + var invokedPresentOfferCodeRedeemSheet = false + var invokedPresentOfferCodeRedeemSheetCount = 0 + + @available(iOS 16.0, *) + func presentOfferCodeRedeemSheet() async throws { + invokedPresentOfferCodeRedeemSheet = true + invokedPresentOfferCodeRedeemSheetCount += 1 + } } diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift index b24200266..7ccf9b076 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // @testable import Flare @@ -41,16 +41,16 @@ final class PurchaseProviderMock: IPurchaseProvider { var invokedPurchase = false var invokedPurchaseCount = 0 - var invokedPurchaseParameters: (product: StoreProduct, Void)? - var invokedPurchaseParametersList = [(product: StoreProduct, Void)]() + var invokedPurchaseParameters: (product: StoreProduct, promotionalOffer: PromotionalOffer?)? + var invokedPurchaseParametersList = [(product: StoreProduct, promotionalOffer: PromotionalOffer?)]() var stubbedPurchaseCompletionResult: (Result, Void)? @MainActor - func purchase(product: StoreProduct, completion: @escaping PurchaseCompletionHandler) { + func purchase(product: StoreProduct, promotionalOffer: PromotionalOffer?, completion: @escaping PurchaseCompletionHandler) { invokedPurchase = true invokedPurchaseCount += 1 - invokedPurchaseParameters = (product, ()) - invokedPurchaseParametersList.append((product, ())) + invokedPurchaseParameters = (product, promotionalOffer) + invokedPurchaseParametersList.append((product, promotionalOffer)) if let result = stubbedPurchaseCompletionResult { completion(result.0) } @@ -58,8 +58,8 @@ final class PurchaseProviderMock: IPurchaseProvider { var invokedPurchaseWithOptions = false var invokedPurchaseWithOptionsCount = 0 - var invokedPurchaseWithOptionsParameters: (product: StoreProduct, Any)? - var invokedPurchaseWithOptionsParametersList = [(product: StoreProduct, Any)]() + var invokedPurchaseWithOptionsParameters: (product: StoreProduct, Any, promotionalOffer: PromotionalOffer?)? + var invokedPurchaseWithOptionsParametersList = [(product: StoreProduct, Any, promotionalOffer: PromotionalOffer?)]() var stubbedinvokedPurchaseWithOptionsCompletionResult: (Result, Void)? @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) @@ -67,12 +67,13 @@ final class PurchaseProviderMock: IPurchaseProvider { func purchase( product: StoreProduct, options: Set, + promotionalOffer: PromotionalOffer?, completion: @escaping PurchaseCompletionHandler ) { invokedPurchaseWithOptions = true invokedPurchaseWithOptionsCount += 1 - invokedPurchaseWithOptionsParameters = (product, options) - invokedPurchaseWithOptionsParametersList.append((product, options)) + invokedPurchaseWithOptionsParameters = (product, options, promotionalOffer) + invokedPurchaseWithOptionsParametersList.append((product, options, promotionalOffer)) if let result = stubbedinvokedPurchaseWithOptionsCompletionResult { completion(result.0) diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/RedeemCodeProvider.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/RedeemCodeProvider.swift new file mode 100644 index 000000000..0f3f03a5f --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/RedeemCodeProvider.swift @@ -0,0 +1,17 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +final class RedeemCodeProviderMock: IRedeemCodeProvider { + var invokedPresentOfferCodeRedeemSheet = false + var invokedPresentOfferCodeRedeemSheetCount = 0 + + func presentOfferCodeRedeemSheet() async { + invokedPresentOfferCodeRedeemSheet = true + invokedPresentOfferCodeRedeemSheetCount += 1 + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/UserDefaultsMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/UserDefaultsMock.swift new file mode 100644 index 000000000..f1a341fec --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/UserDefaultsMock.swift @@ -0,0 +1,35 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +final class UserDefaultsMock: IUserDefaults { + var invokedSet = false + var invokedSetCount = 0 + var invokedSetParameters: (key: String, codable: Any)? + var invokedSetParametersList = [(key: String, codable: Any)]() + + func set(key: String, codable: T) { + invokedSet = true + invokedSetCount += 1 + invokedSetParameters = (key, codable) + invokedSetParametersList.append((key, codable)) + } + + var invokedGet = false + var invokedGetCount = 0 + var invokedGetParameters: (key: String, Void)? + var invokedGetParametersList = [(key: String, Void)]() + var stubbedGetResult: Any! + + func get(key: String) -> T? { + invokedGet = true + invokedGetCount += 1 + invokedGetParameters = (key, ()) + invokedGetParametersList.append((key, ())) + return stubbedGetResult as? T + } +} diff --git a/Tests/IntegrationTests/Flare.storekit b/Tests/IntegrationTests/Flare.storekit index 102d022b8..23d0dd415 100644 --- a/Tests/IntegrationTests/Flare.storekit +++ b/Tests/IntegrationTests/Flare.storekit @@ -4,36 +4,6 @@ ], "products" : [ - { - "displayPrice" : "0.99", - "familyShareable" : false, - "internalID" : "169432A7", - "localizations" : [ - { - "description" : "com.flare.test_purchase_1", - "displayName" : "com.flare.test_purchase_1", - "locale" : "en_US" - } - ], - "productID" : "com.flare.test_purchase_1", - "referenceName" : "com.flare.test_purchase_1", - "type" : "Consumable" - }, - { - "displayPrice" : "0.99", - "familyShareable" : false, - "internalID" : "33E61322", - "localizations" : [ - { - "description" : "com.flare.test_purchase_2", - "displayName" : "com.flare.test_purchase_2", - "locale" : "en_US" - } - ], - "productID" : "com.flare.test_purchase_2", - "referenceName" : "com.flare.test_purchase_2", - "type" : "Consumable" - }, { "displayPrice" : "0.99", "familyShareable" : false, @@ -51,13 +21,222 @@ } ], "settings" : { - + "_failTransactionsEnabled" : false, + "_locale" : "en_US", + "_storefront" : "USA", + "_storeKitErrors" : [ + { + "current" : null, + "enabled" : false, + "name" : "Load Products" + }, + { + "current" : null, + "enabled" : false, + "name" : "Purchase" + }, + { + "current" : null, + "enabled" : false, + "name" : "Verification" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Store Sync" + }, + { + "current" : null, + "enabled" : false, + "name" : "Subscription Status" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Transaction" + }, + { + "current" : null, + "enabled" : false, + "name" : "Manage Subscriptions Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Refund Request Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Offer Code Redeem Sheet" + } + ] }, "subscriptionGroups" : [ + { + "id" : "C3C61FEC", + "localizations" : [ + + ], + "name" : "subscription_group", + "subscriptions" : [ + { + "adHocOffers" : [ + ], + "codeOffers" : [ + + ], + "displayPrice" : "1.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "138CEE33", + "introductoryOffer" : { + "displayPrice" : "0.99", + "internalID" : "970CA16D", + "numberOfPeriods" : 1, + "paymentMode" : "payAsYouGo", + "subscriptionPeriod" : "P1M" + }, + "localizations" : [ + { + "description" : "", + "displayName" : "", + "locale" : "en_US" + } + ], + "productID" : "com.flare.monthly_1.99_week_intro", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Subscription with Introductory Offer", + "subscriptionGroupID" : "C3C61FEC", + "type" : "RecurringSubscription" + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "0.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "9CB5F7A9", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "", + "displayName" : "", + "locale" : "en_US" + } + ], + "productID" : "com.flare.monthly_0.99", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Subscription Without Offers", + "subscriptionGroupID" : "C3C61FEC", + "type" : "RecurringSubscription" + }, + { + "adHocOffers" : [ + { + "internalID" : "479390B8", + "offerID" : "subscription_3_offer", + "paymentMode" : "free", + "referenceName" : "subscription_3_offer", + "subscriptionPeriod" : "P2W" + } + ], + "codeOffers" : [ + + ], + "displayPrice" : "0.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "8D29D6BD", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "", + "displayName" : "", + "locale" : "en_US" + } + ], + "productID" : "com.flare.monthly_1.99_two_weeks_offer.free", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Subscription with Promotional Offer", + "subscriptionGroupID" : "C3C61FEC", + "type" : "RecurringSubscription" + }, + { + "adHocOffers" : [ + { + "displayPrice" : "0.99", + "internalID" : "A05B39A3", + "numberOfPeriods" : 1, + "offerID" : "com.flare.monthly_0.99.1_week_intro", + "paymentMode" : "payAsYouGo", + "referenceName" : "com.flare.monthly_0.99.1_week_intro", + "subscriptionPeriod" : "P1M" + }, + { + "displayPrice" : "1.99", + "internalID" : "79BD229A", + "offerID" : "com.flare.monthly_0.99.1_week_intro", + "paymentMode" : "payUpFront", + "referenceName" : "com.flare.monthly_0.99.1_week_intro", + "subscriptionPeriod" : "P1M" + }, + { + "internalID" : "C181C3BF", + "offerID" : "com.flare.monthly_0.99.1_week_intro", + "paymentMode" : "free", + "referenceName" : "com.flare.monthly_0.99.1_week_intro", + "subscriptionPeriod" : "P1W" + } + ], + "codeOffers" : [ + { + "displayPrice" : "0.99", + "eligibility" : [ + "existing", + "expired", + "new" + ], + "internalID" : "A9D00827", + "isStackable" : true, + "paymentMode" : "payUpFront", + "referenceName" : "offer", + "subscriptionPeriod" : "P1M" + } + ], + "displayPrice" : "0.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "7867CD16", + "introductoryOffer" : { + "displayPrice" : "0.99", + "internalID" : "0A94C45A", + "paymentMode" : "payUpFront", + "subscriptionPeriod" : "P1M" + }, + "localizations" : [ + { + "description" : "Subscription", + "displayName" : "Subscription", + "locale" : "en_US" + } + ], + "productID" : "com.flare.monthly_0.99.1_week_intro", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Subscription Full", + "subscriptionGroupID" : "C3C61FEC", + "type" : "RecurringSubscription" + } + ] + } ], "version" : { - "major" : 2, + "major" : 3, "minor" : 0 } } diff --git a/Tests/IntegrationTests/Helpers/Extensions/AsyncSequence+.swift b/Tests/IntegrationTests/Helpers/Extensions/AsyncSequence+.swift new file mode 100644 index 000000000..b16130182 --- /dev/null +++ b/Tests/IntegrationTests/Helpers/Extensions/AsyncSequence+.swift @@ -0,0 +1,16 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) +extension AsyncSequence { + /// Returns the elements of the asynchronous sequence. + func extractValues() async rethrows -> [Element] { + try await reduce(into: []) { + $0.append($1) + } + } +} diff --git a/Tests/IntegrationTests/Helpers/Providers/ProductProviderHelper.swift b/Tests/IntegrationTests/Helpers/Providers/ProductProviderHelper.swift new file mode 100644 index 000000000..4e35afd6d --- /dev/null +++ b/Tests/IntegrationTests/Helpers/Providers/ProductProviderHelper.swift @@ -0,0 +1,56 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import StoreKit + +// MARK: - ProductProviderHelper + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +enum ProductProviderHelper { + static var purchases: [StoreKit.Product] { + get async throws { + try await StoreKit.Product.products(for: [.testNonConsumableID]) + } + } + + static var subscriptions: [StoreKit.Product] { + get async throws { + try await subscriptionsWithIntroductoryOffer + subscriptionsWithoutOffers + subscriptonsWithOffers + } + } + + static var subscriptionsWithIntroductoryOffer: [StoreKit.Product] { + get async throws { + try await StoreKit.Product.products(for: [.subscription1ID]) + } + } + + static var subscriptionsWithoutOffers: [StoreKit.Product] { + get async throws { + try await StoreKit.Product.products(for: [.subscription2ID]) + } + } + + static var subscriptonsWithOffers: [StoreKit.Product] { + get async throws { + try await StoreKit.Product.products(for: [.subscription3ID]) + } + } +} + +// MARK: - Constants + +private extension String { + static let testNonConsumableID = "com.flare.test_non_consumable_purchase_1" + + /// The subscription's id with introductory offer + static let subscription1ID = "com.flare.monthly_1.99_week_intro" + + /// The subscription's id without introductory offer + static let subscription2ID = "com.flare.monthly_0.99" + + /// The subscription's id with promotional offer + static let subscription3ID = "com.flare.monthly_1.99_two_weeks_offer.free" +} diff --git a/Tests/IntegrationTests/Helpers/StoreSessionTestCase/StoreSessionTestCase.swift b/Tests/IntegrationTests/Helpers/StoreSessionTestCase/StoreSessionTestCase.swift index 4e33a5234..d0923294a 100644 --- a/Tests/IntegrationTests/Helpers/StoreSessionTestCase/StoreSessionTestCase.swift +++ b/Tests/IntegrationTests/Helpers/StoreSessionTestCase/StoreSessionTestCase.swift @@ -1,11 +1,14 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // +import Flare import StoreKitTest import XCTest +// MARK: - StoreSessionTestCase + @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) class StoreSessionTestCase: XCTestCase { // MARK: Properties @@ -29,3 +32,56 @@ class StoreSessionTestCase: XCTestCase { super.tearDown() } } + +@available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) +extension StoreSessionTestCase { + func expireSubscription(product: StoreProduct) { + do { + try session?.expireSubscription(productIdentifier: product.productIdentifier) + } catch { + debugPrint(error.localizedDescription) + } + } + + @available(iOS 15.2, tvOS 15.2, macOS 12.1, watchOS 8.3, *) + func findTransaction(for productID: String) async throws -> Transaction { + let transactions: [Transaction] = await Transaction.currentEntitlements + .compactMap { result in + switch result { + case let .verified(transaction): + return transaction + case .unverified: + return nil + } + } + .filter { (transaction: Transaction) in + transaction.productID == productID + } + .extractValues() + + return try XCTUnwrap(transactions.first) + } + + @available(iOS 15.2, tvOS 15.2, macOS 12.1, watchOS 8.3, *) + func latestTransaction(for productID: String) async throws -> Transaction { + let result: Transaction? = await Transaction.latest(for: productID) + .flatMap { result -> Transaction? in + switch result { + case let .verified(transaction): + return transaction + case .unverified: + return nil + } + } + + return try XCTUnwrap(result) + } + + func clearTransactions() { + session?.clearTransactions() + } + + func forceRenewalOfSubscription(for productIdentifier: String) throws { + try session?.forceRenewalOfSubscription(productIdentifier: productIdentifier) + } +} diff --git a/Tests/IntegrationTests/Tests/EligibilityProviderTests.swift b/Tests/IntegrationTests/Tests/EligibilityProviderTests.swift new file mode 100644 index 000000000..ec7e48152 --- /dev/null +++ b/Tests/IntegrationTests/Tests/EligibilityProviderTests.swift @@ -0,0 +1,50 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import XCTest + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +final class EligibilityProviderTests: StoreSessionTestCase { + // MARK: Properties + + private var sut: EligibilityProvider! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + sut = EligibilityProvider() + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatProviderReturnsNoOffer_whenProductDoesNotHaveIntroductoryOffer() async throws { + // given + let product = try await ProductProviderHelper.subscriptionsWithoutOffers.randomElement()! + + // when + let result = try await sut.checkEligibility(products: [StoreProduct(product: product)]) + + // then + XCTAssertEqual(result[product.id], .noOffer) + } + + func test_thatProviderReturnsEligible_whenProductHasIntroductoryOffer() async throws { + // given + let product = try await ProductProviderHelper.subscriptionsWithIntroductoryOffer.randomElement()! + + // when + let result = try await sut.checkEligibility(products: [StoreProduct(product: product)]) + + // then + XCTAssertEqual(result[product.id], .eligible) + } +} diff --git a/Tests/IntegrationTests/Tests/FlareTests.swift b/Tests/IntegrationTests/Tests/FlareTests.swift index c3ff08793..1fc5b9b87 100644 --- a/Tests/IntegrationTests/Tests/FlareTests.swift +++ b/Tests/IntegrationTests/Tests/FlareTests.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // @testable import Flare @@ -82,6 +82,54 @@ final class FlareTests: StoreSessionTestCase { ) } + @available(iOS 15.2, tvOS 15.2, macOS 12.1, watchOS 8.3, *) + func test_thatPurchaseIntorudctoryOffer() async throws { + // 1. Fetch a product + let product = try await ProductProviderHelper.subscriptionsWithIntroductoryOffer.randomElement()! + let storeProduct = StoreProduct(product: product) + + // 2. Checking eligibility for a product + var eligibleResult = try await Flare.shared.checkEligibility(productIDs: [product.id])[product.id] + XCTAssertEqual(eligibleResult, .eligible) + + // 3. Purchase the product + let purchaseTransaction = try await sut.purchase(product: storeProduct) + + // 5. Retrieve a transaction + var transaction = try await findTransaction(for: product.id) + + // 6. Checking transaction + XCTAssertEqual(transaction.productID, product.id) + XCTAssertEqual(transaction.offerType, .introductory) + + // 7. Finish the transaction + let expectation = XCTestExpectation(description: "Finishing the transaction") + sut.finish(transaction: purchaseTransaction) { expectation.fulfill() } + + #if swift(>=5.9) + await fulfillment(of: [expectation]) + #else + wait(for: [expectation], timeout: .second) + #endif + + // 8. Checking eligibility for the purchased product + eligibleResult = try await Flare.shared.checkEligibility(productIDs: [product.id])[product.id] + XCTAssertEqual(eligibleResult, .nonEligible) + + // 9. Expire subscription + expireSubscription(product: storeProduct) + + // 10. Purchase the same product again + _ = try await sut.purchase(product: storeProduct) + + // 11. Retrieve a transaction + transaction = try await latestTransaction(for: product.id) + + // 12. Checking the transaction + XCTAssertEqual(transaction.productID, product.id) + XCTAssertEqual(transaction.offerType, nil) + } + // MARK: Private private func test_purchaseWithOptionsAndCompletion( diff --git a/Tests/IntegrationTests/Tests/IAPProviderTests.swift b/Tests/IntegrationTests/Tests/IAPProviderTests.swift deleted file mode 100644 index d1c0d99d4..000000000 --- a/Tests/IntegrationTests/Tests/IAPProviderTests.swift +++ /dev/null @@ -1,151 +0,0 @@ -// -// Flare -// Copyright © 2023 Space Code. All rights reserved. -// - -////// -////// Flare -////// Copyright © 2023 Space Code. All rights reserved. -////// -// -// @testable import Flare -// import XCTest -// -//// MARK: - IAPProviderStoreKit2Tests -// -// @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) -// final class IAPProviderStoreKit2Tests: StoreSessionTestCase { -// // MARK: - Properties -// -// private var productProviderMock: ProductProviderMock! -// private var purchaseProvider: PurchaseProviderMock! -// private var refundProviderMock: RefundProviderMock! -// -// private var sut: IIAPProvider! -// -// // MARK: - XCTestCase -// -// override func setUp() { -// super.setUp() -// productProviderMock = ProductProviderMock() -// purchaseProvider = PurchaseProviderMock() -// refundProviderMock = RefundProviderMock() -// sut = IAPProvider( -// paymentQueue: PaymentQueueMock(), -// productProvider: productProviderMock, -// purchaseProvider: purchaseProvider, -// receiptRefreshProvider: ReceiptRefreshProviderMock(), -// refundProvider: refundProviderMock -// ) -// } -// -// override func tearDown() { -// productProviderMock = nil -// purchaseProvider = nil -// refundProviderMock = nil -// sut = nil -// super.tearDown() -// } -// -// // MARK: Tests -// -// func test_thatIAPProviderFetchesSK2Products_whenProductsAreAvailable() async throws { -// let productsMock = try await ProductProviderHelper.purchases.map(SK2StoreProduct.init) -// productProviderMock.stubbedAsyncFetchResult = .success(productsMock) -// -// // when -// let products = try await sut.fetch(productIDs: [.productID]) -// -// // then -// XCTAssertFalse(products.isEmpty) -// XCTAssertEqual(productsMock.count, products.count) -// } -// -// func test_thatIAPProviderThrowsAnIAPError_whenFetchingProductsFailed() async { -// productProviderMock.stubbedAsyncFetchResult = .failure(IAPError.unknown) -// -// // when -// let error: IAPError? = await error(for: { try await sut.fetch(productIDs: [.productID]) }) -// -// // then -// XCTAssertEqual(error, .unknown) -// } -// -// func test_thatIAPProviderThrowsAPlainError_whenFetchingProductsFailed() async { -// productProviderMock.stubbedAsyncFetchResult = .failure(URLError(.unknown)) -// -// // when -// let error: IAPError? = await error(for: { try await sut.fetch(productIDs: [.productID]) }) -// -// // then -// XCTAssertEqual(error, .with(error: URLError(.unknown))) -// } -// -// #if os(iOS) || VISION_OS -// func test_thatIAPProviderRefundsPurchase() async throws { -// // given -// refundProviderMock.stubbedBeginRefundRequest = .success -// -// // when -// let state = try await sut.beginRefundRequest(productID: .productID) -// -// // then -// if case .success = state {} -// else { XCTFail("state must be `success`") } -// } -// -// func test_thatFlareThrowsAnError_whenBeginRefundRequestFailed() async throws { -// // given -// refundProviderMock.stubbedBeginRefundRequest = .failed(error: IAPError.unknown) -// -// // when -// let state = try await sut.beginRefundRequest(productID: .productID) -// -// // then -// if case let .failed(error) = state { XCTAssertEqual(error as NSError, IAPError.unknown as NSError) } -// else { XCTFail("state must be `failed`") } -// } -// #endif -// -// func test_thatIAPProviderPurchasesAProduct() async throws { -// // given -// let transactionMock = StoreTransactionMock() -// transactionMock.stubbedTransactionIdentifier = .transactionID -// -// let storeTransaction = StoreTransaction(storeTransaction: transactionMock) -// purchaseProvider.stubbedPurchaseCompletionResult = (.success(storeTransaction), ()) -// -// let product = try await ProductProviderHelper.purchases[0] -// -// // when -// let transaction = try await sut.purchase(product: StoreProduct(product: product)) -// -// // then -// XCTAssertEqual(transaction.transactionIdentifier, .transactionID) -// } -// -// func test_thatIAPProviderPurchasesAProductWithOptions() async throws { -// // given -// let transactionMock = StoreTransactionMock() -// transactionMock.stubbedTransactionIdentifier = .transactionID -// -// let storeTransaction = StoreTransaction(storeTransaction: transactionMock) -// purchaseProvider.stubbedinvokedPurchaseWithOptionsCompletionResult = (.success(storeTransaction), ()) -// -// let product = try await ProductProviderHelper.purchases[0] -// -// // when -// let transaction = try await sut.purchase(product: StoreProduct(product: product), options: []) -// -// // then -// XCTAssertEqual(transaction.transactionIdentifier, .transactionID) -// } -// } -// -//// MARK: - Constants -// -// private extension String { -//// static let receipt = "receipt" -// static let productID = "product_identifier" -// static let transactionID = "transaction_identifier" -// } diff --git a/Tests/IntegrationTests/Tests/ProductProviderHelper.swift b/Tests/IntegrationTests/Tests/ProductProviderHelper.swift deleted file mode 100644 index 2deb3d05b..000000000 --- a/Tests/IntegrationTests/Tests/ProductProviderHelper.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// Flare -// Copyright © 2023 Space Code. All rights reserved. -// - -import StoreKit - -// MARK: - ProductProviderHelper - -@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) -enum ProductProviderHelper { - static var purchases: [StoreKit.Product] { - get async throws { - try await StoreKit.Product.products(for: [.testNonConsumableID]) - } - } - -// static var subscriptions: [StoreKit.Product] { -// get async throws { -// try await StoreKit.Product.products(for: [.testSubscription1ID, .testSubscription2ID]) -// } -// } -// -// static var all: [StoreKit.Product] { -// get async throws { -// let purchases = try await self.purchases -// let subscriptions = try await self.subscriptions -// -// return purchases + subscriptions -// } -// } -} - -// MARK: - Constants - -private extension String { - static let testPurchase1ID = "com.flare.test_purchase_1" - static let testPurchase2ID = "com.flare.test_purchase_2" - - static let testNonConsumableID = "com.flare.test_non_consumable_purchase_1" - -// static let testSubscription1ID = "com.flare.test_subscription_1" -// static let testSubscription2ID = "com.flare.test_subscription_2" -} diff --git a/Tests/IntegrationTests/Tests/ProductProviderTests.swift b/Tests/IntegrationTests/Tests/ProductProviderTests.swift deleted file mode 100644 index 3edf243fb..000000000 --- a/Tests/IntegrationTests/Tests/ProductProviderTests.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// Flare -// Copyright © 2023 Space Code. All rights reserved. -// - -//// -//// Flare -//// Copyright © 2023 Space Code. All rights reserved. -//// -// -// import Concurrency -// @testable import Flare -// import TestConcurrency -// import XCTest -// -//// MARK: - ProductProviderStoreKit2Tests -// -// @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) -// final class ProductProviderStoreKit2Tests: StoreSessionTestCase { -// // MARK: - Properties -// -// private var testDispatchQueue: TestDispatchQueue! -// private var dispatchQueueFactory: IDispatchQueueFactory! -// -// private var sut: ProductProvider! -// -// // MARK: - XCTestCase -// -// override func setUp() { -// super.setUp() -// testDispatchQueue = TestDispatchQueue() -// dispatchQueueFactory = TestDispatchQueueFactory(testQueue: testDispatchQueue) -// sut = ProductProvider(dispatchQueueFactory: dispatchQueueFactory) -// } -// -// override func tearDown() { -// testDispatchQueue = nil -// dispatchQueueFactory = nil -// sut = nil -// super.tearDown() -// } -// -// // MARK: - Tests -// -// func test_thatProductProviderFetchesProductsWithIDs() async throws { -// // when -// let products = try await sut.fetch(productIDs: [.productID]) -// -// // then -// XCTAssertEqual(products.count, 1) -// XCTAssertEqual(products.first?.productIdentifier, .productID) -// } -// } -// -//// MARK: - Constants -// -// private extension String { -// static let productID = "com.flare.test_purchase_1" -// } diff --git a/Tests/IntegrationTests/Tests/PurchaseProviderTests.swift b/Tests/IntegrationTests/Tests/PurchaseProviderTests.swift deleted file mode 100644 index 31170ffb3..000000000 --- a/Tests/IntegrationTests/Tests/PurchaseProviderTests.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// Flare -// Copyright © 2023 Space Code. All rights reserved. -// - -//// -//// Flare -//// Copyright © 2023 Space Code. All rights reserved. -//// -// -// @testable import Flare -// import XCTest -// -//// MARK: - PurchaseProviderStoreKit2Tests -// -// @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) -// final class PurchaseProviderStoreKit2Tests: StoreSessionTestCase { -// // MARK: Properties -// -// private var paymentProviderMock: PaymentProviderMock! -// -// private var sut: PurchaseProvider! -// -// // MARK: XCTestCase -// -// override func setUp() { -// super.setUp() -// paymentProviderMock = PaymentProviderMock() -// sut = PurchaseProvider( -// paymentProvider: paymentProviderMock -// ) -// } -// -// override func tearDown() { -// sut = nil -// super.tearDown() -// } -// -// // MARK: Tests -// -// func test_thatPurchaseProviderReturnsPaymentTransaction_whenPurchasesAProductWithOptions() async throws { -// let expectation = XCTestExpectation(description: "Purchase a product") -// let productMock = try StoreProduct(product: await ProductProviderHelper.purchases.randomElement()!) -// -// // when -// sut.purchase(product: productMock, options: [.simulatesAskToBuyInSandbox(false)]) { result in -// switch result { -// case let .success(transaction): -// XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) -// expectation.fulfill() -// case let .failure(error): -// XCTFail(error.localizedDescription) -// } -// } -// -// #if swift(>=5.9) -// await fulfillment(of: [expectation]) -// #else -// wait(for: [expectation], timeout: .second) -// #endif -// } -// -// func test_thatPurchaseProviderReturnsPaymentTransaction_whenSK2ProductExist() async throws { -// let expectation = XCTestExpectation(description: "Purchase a product") -// let productMock = try StoreProduct(product: await ProductProviderHelper.purchases.randomElement()!) -// -// // when -// sut.purchase(product: productMock) { result in -// switch result { -// case let .success(transaction): -// XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) -// expectation.fulfill() -// case let .failure(error): -// XCTFail(error.localizedDescription) -// } -// } -// -// #if swift(>=5.9) -// await fulfillment(of: [expectation]) -// #else -// wait(for: [expectation], timeout: .second) -// #endif -// } -// } -// -//// MARK: - Constants -// -// private extension TimeInterval { -// static let second: TimeInterval = 1.0 -// } diff --git a/Tests/IntegrationTests/Tests/StoreProductTests.swift b/Tests/IntegrationTests/Tests/StoreProductTests.swift new file mode 100644 index 000000000..91432b7e2 --- /dev/null +++ b/Tests/IntegrationTests/Tests/StoreProductTests.swift @@ -0,0 +1,173 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit +import XCTest + +// MARK: - StoreProductTests + +@available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) +final class StoreProductTests: StoreSessionTestCase { + // MARK: Private + + private var provider: IProductProvider! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + provider = ProductProvider() + } + + override func tearDown() { + provider = nil + super.tearDown() + } + + // MARK: - Tests + + func test_sk1ProductWrapsCorrectly() async throws { + // given + let expectation = XCTestExpectation(description: "Purchase a product") + + // when + var products: [StoreProduct] = [] + provider.fetch(productIDs: [String.productID], requestID: UUID().uuidString) { result in + switch result { + case let .success(skProducts): + products = skProducts.map { StoreProduct($0) } + case .failure: + break + } + expectation.fulfill() + } + + #if swift(>=5.9) + await fulfillment(of: [expectation]) + #else + wait(for: [expectation], timeout: .seconds) + #endif + + // then + let storeProduct = try XCTUnwrap(products.first) + + // then + XCTAssertEqual(storeProduct.productIdentifier, .productID) + XCTAssertEqual(storeProduct.productCategory, .subscription) + XCTAssertEqual(storeProduct.productType, nil) + XCTAssertEqual(storeProduct.localizedDescription, "Subscription") + XCTAssertEqual(storeProduct.localizedTitle, "Subscription") + XCTAssertEqual(storeProduct.currencyCode, "USD") + XCTAssertEqual(storeProduct.price.description, "0.99") + XCTAssertEqual(storeProduct.localizedPriceString, "$0.99") + XCTAssertEqual(storeProduct.subscriptionGroupIdentifier, "C3C61FEC") + + XCTAssertEqual(storeProduct.subscriptionPeriod?.unit, .month) + XCTAssertEqual(storeProduct.subscriptionPeriod?.value, 1) + + let intro = try XCTUnwrap(storeProduct.introductoryDiscount) + + XCTAssertEqual(intro.price, 0.99) + XCTAssertEqual(intro.paymentMode, .payUpFront) + XCTAssertEqual(intro.type, .introductory) + XCTAssertEqual(intro.offerIdentifier, nil) + XCTAssertEqual(intro.subscriptionPeriod, SubscriptionPeriod(value: 1, unit: .month)) + + let offers = try XCTUnwrap(storeProduct.discounts) + XCTAssertEqual(offers.count, 3) + + XCTAssertEqual(offers[0].price, 0.99) + XCTAssertEqual(offers[0].paymentMode, .payAsYouGo) + XCTAssertEqual(offers[0].type, .promotional) + XCTAssertEqual(offers[0].offerIdentifier, "com.flare.monthly_0.99.1_week_intro") + XCTAssertEqual(offers[0].subscriptionPeriod, SubscriptionPeriod(value: 1, unit: .month)) + XCTAssertEqual(offers[0].numberOfPeriods, 1) + XCTAssertEqual(offers[0].currencyCode, "USD") + + XCTAssertEqual(offers[1].price, 1.99) + XCTAssertEqual(offers[1].paymentMode, .payUpFront) + XCTAssertEqual(offers[1].type, .promotional) + XCTAssertEqual(offers[1].offerIdentifier, "com.flare.monthly_0.99.1_week_intro") + XCTAssertEqual(offers[1].subscriptionPeriod, SubscriptionPeriod(value: 1, unit: .month)) + XCTAssertEqual(offers[1].numberOfPeriods, 1) + XCTAssertEqual(offers[1].currencyCode, "USD") + + XCTAssertEqual(offers[2].price, 0) + XCTAssertEqual(offers[2].paymentMode, .freeTrial) + XCTAssertEqual(offers[2].type, .promotional) + XCTAssertEqual(offers[2].offerIdentifier, "com.flare.monthly_0.99.1_week_intro") + XCTAssertEqual(offers[2].subscriptionPeriod, SubscriptionPeriod(value: 1, unit: .week)) + XCTAssertEqual(offers[2].numberOfPeriods, 1) + XCTAssertEqual(offers[2].currencyCode, "USD") + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func test_sk2ProductWrapsCorrectly() async throws { + // given + let products = try await StoreKit.Product.products(for: [String.productID]) + let product = try XCTUnwrap(products.first) + let storeProduct = StoreProduct(product: product) + + // then + XCTAssertEqual(storeProduct.productIdentifier, .productID) + XCTAssertEqual(storeProduct.productCategory, .subscription) + XCTAssertEqual(storeProduct.productType, .autoRenewableSubscription) + XCTAssertEqual(storeProduct.localizedDescription, "Subscription") + XCTAssertEqual(storeProduct.localizedTitle, "Subscription") + XCTAssertEqual(storeProduct.currencyCode, "USD") + XCTAssertEqual(storeProduct.price.description, "0.99") + XCTAssertEqual(storeProduct.localizedPriceString, "$0.99") + XCTAssertEqual(storeProduct.subscriptionGroupIdentifier, "C3C61FEC") + + XCTAssertEqual(storeProduct.subscriptionPeriod?.unit, .month) + XCTAssertEqual(storeProduct.subscriptionPeriod?.value, 1) + + let intro = try XCTUnwrap(storeProduct.introductoryDiscount) + + XCTAssertEqual(intro.price, 0.99) + XCTAssertEqual(intro.paymentMode, .payUpFront) + XCTAssertEqual(intro.type, .introductory) + XCTAssertEqual(intro.offerIdentifier, nil) + XCTAssertEqual(intro.subscriptionPeriod, SubscriptionPeriod(value: 1, unit: .month)) + + let offers = try XCTUnwrap(storeProduct.discounts) + XCTAssertEqual(offers.count, 3) + + XCTAssertEqual(offers[0].price, 0.99) + XCTAssertEqual(offers[0].paymentMode, .payAsYouGo) + XCTAssertEqual(offers[0].type, .promotional) + XCTAssertEqual(offers[0].offerIdentifier, "com.flare.monthly_0.99.1_week_intro") + XCTAssertEqual(offers[0].subscriptionPeriod, SubscriptionPeriod(value: 1, unit: .month)) + XCTAssertEqual(offers[0].numberOfPeriods, 1) + XCTAssertEqual(offers[0].currencyCode, "USD") + + XCTAssertEqual(offers[1].price, 1.99) + XCTAssertEqual(offers[1].paymentMode, .payUpFront) + XCTAssertEqual(offers[1].type, .promotional) + XCTAssertEqual(offers[1].offerIdentifier, "com.flare.monthly_0.99.1_week_intro") + XCTAssertEqual(offers[1].subscriptionPeriod, SubscriptionPeriod(value: 1, unit: .month)) + XCTAssertEqual(offers[1].numberOfPeriods, 1) + XCTAssertEqual(offers[1].currencyCode, "USD") + + XCTAssertEqual(offers[2].price, 0) + XCTAssertEqual(offers[2].paymentMode, .freeTrial) + XCTAssertEqual(offers[2].type, .promotional) + XCTAssertEqual(offers[2].offerIdentifier, "com.flare.monthly_0.99.1_week_intro") + XCTAssertEqual(offers[2].subscriptionPeriod, SubscriptionPeriod(value: 1, unit: .week)) + XCTAssertEqual(offers[2].numberOfPeriods, 1) + XCTAssertEqual(offers[2].currencyCode, "USD") + } +} + +// MARK: - Constants + +private extension String { + static let productID = "com.flare.monthly_0.99.1_week_intro" +} + +private extension TimeInterval { + static let seconds: CGFloat = 60.0 +}