From d083950a0471e1fc4797c4db0ff1d41d4186747e Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Mon, 15 Jan 2024 14:13:28 +0100 Subject: [PATCH 01/17] Implement discount models --- .../Flare/Classes/Models/DiscountType.swift | 52 ++++++++++++ Sources/Flare/Classes/Models/IAPError.swift | 4 +- .../Internal/Protocols/ISKProduct.swift | 8 +- .../Protocols/IStoreProductDiscount.swift | 32 ++++++++ .../Models/Internal/SK1StoreProduct.swift | 10 ++- .../Internal/SK1StoreProductDiscount.swift | 60 ++++++++++++++ .../Models/Internal/SK2StoreProduct.swift | 15 +++- .../Internal/SK2StoreProductDiscount.swift | 62 ++++++++++++++ .../Flare/Classes/Models/PaymentMode.swift | 59 +++++++++++++ .../Flare/Classes/Models/StoreProduct.swift | 8 ++ .../Classes/Models/StoreProductDiscount.swift | 82 +++++++++++++++++++ .../Classes/Models/SubscriptionPeriod.swift | 15 +++- 12 files changed, 399 insertions(+), 8 deletions(-) create mode 100644 Sources/Flare/Classes/Models/DiscountType.swift create mode 100644 Sources/Flare/Classes/Models/Internal/Protocols/IStoreProductDiscount.swift create mode 100644 Sources/Flare/Classes/Models/Internal/SK1StoreProductDiscount.swift create mode 100644 Sources/Flare/Classes/Models/Internal/SK2StoreProductDiscount.swift create mode 100644 Sources/Flare/Classes/Models/PaymentMode.swift create mode 100644 Sources/Flare/Classes/Models/StoreProductDiscount.swift diff --git a/Sources/Flare/Classes/Models/DiscountType.swift b/Sources/Flare/Classes/Models/DiscountType.swift new file mode 100644 index 000000000..8cd51df69 --- /dev/null +++ b/Sources/Flare/Classes/Models/DiscountType.swift @@ -0,0 +1,52 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - DiscountType + +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..06052a6e9 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,7 +34,7 @@ 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 diff --git a/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift b/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift index 825632ca4..1ffa34f38 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,10 @@ protocol ISKProduct { /// The subscription period for the product, if applicable. var subscriptionPeriod: SubscriptionPeriod? { get } + + /// <#Description#> + var introductoryDiscount: StoreProductDiscount? { get } + + /// <#Description#> + var discounts: [StoreProductDiscount] { 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..6acd91b5a 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 @@ -69,4 +69,12 @@ extension SK1StoreProduct: ISKProduct { } return SubscriptionPeriod.from(subscriptionPeriod: subscriptionPeriod) } + + var introductoryDiscount: StoreProductDiscount? { + product.introductoryPrice.flatMap { StoreProductDiscount(skProductDiscount: $0) } + } + + var discounts: [StoreProductDiscount] { + product.discounts.compactMap { StoreProductDiscount(skProductDiscount: $0) } + } } diff --git a/Sources/Flare/Classes/Models/Internal/SK1StoreProductDiscount.swift b/Sources/Flare/Classes/Models/Internal/SK1StoreProductDiscount.swift new file mode 100644 index 000000000..a272c03d7 --- /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: Initializaiton + + /// 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 = "" + 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..4fd67ad64 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,16 @@ 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) + } ?? [] + } } diff --git a/Sources/Flare/Classes/Models/Internal/SK2StoreProductDiscount.swift b/Sources/Flare/Classes/Models/Internal/SK2StoreProductDiscount.swift new file mode 100644 index 000000000..f517eccd9 --- /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: Initializaiton + + /// 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/StoreProduct.swift b/Sources/Flare/Classes/Models/StoreProduct.swift index 06255b5a2..62b7c49ca 100644 --- a/Sources/Flare/Classes/Models/StoreProduct.swift +++ b/Sources/Flare/Classes/Models/StoreProduct.swift @@ -85,4 +85,12 @@ extension StoreProduct: ISKProduct { var subscriptionPeriod: SubscriptionPeriod? { product.subscriptionPeriod } + + var introductoryDiscount: StoreProductDiscount? { + product.introductoryDiscount + } + + var discounts: [StoreProductDiscount] { + product.discounts + } } diff --git a/Sources/Flare/Classes/Models/StoreProductDiscount.swift b/Sources/Flare/Classes/Models/StoreProductDiscount.swift new file mode 100644 index 000000000..76f1cc497 --- /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 { + var offerIdentifier: String? { + discount.offerIdentifier + } + + var currencyCode: String? { + discount.currencyCode + } + + var price: Decimal { + discount.price + } + + var paymentMode: PaymentMode { + discount.paymentMode + } + + var subscriptionPeriod: SubscriptionPeriod { + discount.subscriptionPeriod + } + + var numberOfPeriods: Int { + discount.numberOfPeriods + } + + var type: DiscountType { + discount.type + } +} diff --git a/Sources/Flare/Classes/Models/SubscriptionPeriod.swift b/Sources/Flare/Classes/Models/SubscriptionPeriod.swift index 9bf8a2f52..c9660e1d4 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. @@ -66,7 +66,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 +86,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 { From 2950cdbabf3d5f24fd39077d7a8450d4697836a6 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Mon, 15 Jan 2024 18:02:51 +0100 Subject: [PATCH 02/17] Implement `PromotionalOffer` model --- Sources/Flare/Classes/Flare.swift | 20 ++-- Sources/Flare/Classes/IFlare.swift | 101 ++++++++++++++++- Sources/Flare/Classes/Models/IAPError.swift | 4 + .../Internal/Protocols/ISKProduct.swift | 2 + .../Models/Internal/SK1StoreProduct.swift | 10 +- .../Internal/SK1StoreProductDiscount.swift | 2 +- .../Models/Internal/SK2StoreProduct.swift | 4 + .../Internal/SK2StoreProductDiscount.swift | 2 +- .../Classes/Models/PromotionalOffer.swift | 90 +++++++++++++++ .../Flare/Classes/Models/StoreProduct.swift | 26 +++-- .../Classes/Models/StoreProductDiscount.swift | 14 +-- .../Providers/IAPProvider/IAPProvider.swift | 25 +++-- .../Providers/IAPProvider/IIAPProvider.swift | 103 +++++++++++++++++- .../PurchaseProvider/IPurchaseProvider.swift | 40 ++++++- .../PurchaseProvider/PurchaseProvider.swift | 30 ++++- .../TestHelpers/Mocks/IAPProviderMock.swift | 50 ++++++++- .../Mocks/PurchaseProviderMock.swift | 21 ++-- 17 files changed, 476 insertions(+), 68 deletions(-) create mode 100644 Sources/Flare/Classes/Models/PromotionalOffer.swift diff --git a/Sources/Flare/Classes/Flare.swift b/Sources/Flare/Classes/Flare.swift index f27396447..d9c68b7ae 100644 --- a/Sources/Flare/Classes/Flare.swift +++ b/Sources/Flare/Classes/Flare.swift @@ -44,13 +44,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 +64,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>) { diff --git a/Sources/Flare/Classes/IFlare.swift b/Sources/Flare/Classes/IFlare.swift index 6643cdd14..bcb6af2e4 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. /// @@ -129,3 +145,80 @@ public protocol IFlare { func beginRefundRequest(productID: String) async throws -> RefundRequestStatus #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/IAPError.swift b/Sources/Flare/Classes/Models/IAPError.swift index 06052a6e9..db0f5541c 100644 --- a/Sources/Flare/Classes/Models/IAPError.swift +++ b/Sources/Flare/Classes/Models/IAPError.swift @@ -38,6 +38,10 @@ public enum IAPError: Swift.Error { /// /// - 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 } diff --git a/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift b/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift index 1ffa34f38..7e4dcdad1 100644 --- a/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift +++ b/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift @@ -39,4 +39,6 @@ protocol ISKProduct { /// <#Description#> var discounts: [StoreProductDiscount] { get } + + var subscriptionGroupIdentifier: String? { get } } diff --git a/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift b/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift index 6acd91b5a..44722d38a 100644 --- a/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift +++ b/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift @@ -56,13 +56,9 @@ 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 @@ -77,4 +73,8 @@ extension SK1StoreProduct: ISKProduct { 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 index a272c03d7..559810d58 100644 --- a/Sources/Flare/Classes/Models/Internal/SK1StoreProductDiscount.swift +++ b/Sources/Flare/Classes/Models/Internal/SK1StoreProductDiscount.swift @@ -33,7 +33,7 @@ struct SK1StoreProductDiscount: IStoreProductDiscount { /// The type of discount (e.g., introductory, promotional). let type: DiscountType - // MARK: Initializaiton + // MARK: Initialization /// Creates a `SK1StoreProductDiscount` instance. /// diff --git a/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift b/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift index 4fd67ad64..ad99a8987 100644 --- a/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift +++ b/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift @@ -81,4 +81,8 @@ extension SK2StoreProduct: ISKProduct { 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 index f517eccd9..f38ba0cef 100644 --- a/Sources/Flare/Classes/Models/Internal/SK2StoreProductDiscount.swift +++ b/Sources/Flare/Classes/Models/Internal/SK2StoreProductDiscount.swift @@ -34,7 +34,7 @@ struct SK2StoreProductDiscount: IStoreProductDiscount, Sendable { /// The type of discount (e.g., introductory, promotional). let type: DiscountType - // MARK: Initializaiton + // MARK: Initialization /// Creates a `SK2StoreProductDiscount` instance. /// diff --git a/Sources/Flare/Classes/Models/PromotionalOffer.swift b/Sources/Flare/Classes/Models/PromotionalOffer.swift new file mode 100644 index 000000000..b17fbb1ca --- /dev/null +++ b/Sources/Flare/Classes/Models/PromotionalOffer.swift @@ -0,0 +1,90 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - PromotionalOffer + +public final class PromotionalOffer: NSObject, Sendable { + // MARK: Properties + + public let discount: StoreProductDiscount + public let signedData: SignedData + + // MARK: Initialization + + public init(discount: StoreProductDiscount, signedData: SignedData) { + self.discount = discount + self.signedData = signedData + } +} + +// MARK: PromotionalOffer.SignedData + +public extension PromotionalOffer { + final class SignedData: NSObject, Sendable { + // MARK: Properties + + public let identifier: String + public let keyIdentifier: String + public let nonce: UUID + public let signature: String + public let timestamp: Int + + 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 { + 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 62b7c49ca..bfbb34666 100644 --- a/Sources/Flare/Classes/Models/StoreProduct.swift +++ b/Sources/Flare/Classes/Models/StoreProduct.swift @@ -50,47 +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 } - var introductoryDiscount: StoreProductDiscount? { + public var introductoryDiscount: StoreProductDiscount? { product.introductoryDiscount } - var discounts: [StoreProductDiscount] { + 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 index 76f1cc497..871755058 100644 --- a/Sources/Flare/Classes/Models/StoreProductDiscount.swift +++ b/Sources/Flare/Classes/Models/StoreProductDiscount.swift @@ -52,31 +52,31 @@ public extension StoreProductDiscount { // MARK: IStoreProductDiscount extension StoreProductDiscount: IStoreProductDiscount { - var offerIdentifier: String? { + public var offerIdentifier: String? { discount.offerIdentifier } - var currencyCode: String? { + public var currencyCode: String? { discount.currencyCode } - var price: Decimal { + public var price: Decimal { discount.price } - var paymentMode: PaymentMode { + public var paymentMode: PaymentMode { discount.paymentMode } - var subscriptionPeriod: SubscriptionPeriod { + public var subscriptionPeriod: SubscriptionPeriod { discount.subscriptionPeriod } - var numberOfPeriods: Int { + public var numberOfPeriods: Int { discount.numberOfPeriods } - var type: DiscountType { + public var type: DiscountType { discount.type } } diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift index f2ed8c0a0..66b01394d 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 @@ -80,8 +80,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 +95,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 +107,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) } } diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift index ac7cf0bac..21f55becb 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. /// @@ -132,3 +148,80 @@ public protocol IIAPProvider { func beginRefundRequest(productID: String) async throws -> RefundRequestStatus #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/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..6514b8baf 100644 --- a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift @@ -42,9 +42,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 = "" // TODO: + payment.paymentDiscount = promotionalOffer?.signedData.skPromotionalOffer paymentProvider.add(payment: payment) { _, result in Task { switch result { @@ -61,6 +64,7 @@ final class PurchaseProvider { private func purchase( sk2StoreProduct: SK2StoreProduct, options: Set? = nil, + promotionalOffer: PromotionalOffer?, completion: @escaping @MainActor (Result) -> Void ) { AsyncHandler.call(completion: { result in @@ -77,7 +81,11 @@ final class PurchaseProvider { } } }, asyncMethod: { - try await sk2StoreProduct.product.purchase(options: options ?? []) + var options: Set = options ?? [] + if let promotionalOffer { + try options.insert(promotionalOffer.signedData.promotionalOffer) + } + return try await sk2StoreProduct.product.purchase(options: options) }) } } @@ -85,13 +93,17 @@ final class PurchaseProvider { // 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 +111,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/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift index e6a194485..e83dc1a4d 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,52 @@ final class IAPProviderMock: IIAPProvider { invokedAsyncPurchaseWithOptionsParametersList.append((product, options)) return stubbedAsyncPurchaseWithOptions } + + func promotionalOffer( + productDiscount _: StoreProductDiscount, + product _: StoreProduct, + completion _: @escaping @Sendable (Result) -> Void + ) {} + + func purchase( + product _: StoreProduct, + promotionalOffer _: PromotionalOffer?, + completion _: @escaping Closure> + ) {} + + var invokedPurchaseWithPromotionalOffer = false + var invokedPurchaseWithPromotionalOfferCount = 0 + var stubbedPurchaseWithPromotionalOffer: StoreTransaction! + + func purchase( + product _: StoreProduct, + promotionalOffer _: PromotionalOffer? + ) async throws -> StoreTransaction { + invokedPurchaseWithPromotionalOffer = true + invokedPurchaseWithPromotionalOfferCount += 1 + return stubbedPurchaseWithPromotionalOffer + } + + @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 + } } 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) From e6dba40ac81123e61b6f7d9c77f7d4665cfb827c Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Mon, 15 Jan 2024 18:40:23 +0100 Subject: [PATCH 03/17] Update tests --- .../Providers/IAPProvider/IAPProvider.swift | 2 +- .../PurchaseProvider/PurchaseProvider.swift | 2 +- Tests/FlareTests/UnitTests/FlareTests.swift | 16 ++++--- .../TestHelpers/Mocks/IAPProviderMock.swift | 47 ++++++++++++------- 4 files changed, 41 insertions(+), 26 deletions(-) diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift index 66b01394d..215d5dbab 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -55,7 +55,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: { diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift index 6514b8baf..47af7a869 100644 --- a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift @@ -67,7 +67,7 @@ final class PurchaseProvider { 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): diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index 33cc4debc..274d61254 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 @@ -64,8 +64,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 +83,7 @@ class FlareTests: XCTestCase { // given let paymentTransaction = StoreTransaction(storeTransaction: StoreTransactionStub()) iapProviderMock.stubbedCanMakePayments = true + iapProviderMock.stubbedPurchaseWithPromotionalOffer = .success(paymentTransaction) // when var transaction: IStoreTransaction? @@ -92,7 +93,7 @@ class FlareTests: XCTestCase { iapProviderMock.invokedPurchaseParameters?.completion(.success(paymentTransaction)) // then - XCTAssertTrue(iapProviderMock.invokedPurchase) + XCTAssertTrue(iapProviderMock.invokedPurchaseWithPromotionalOffer) XCTAssertEqual(transaction?.productIdentifier, paymentTransaction.productIdentifier) } @@ -100,6 +101,7 @@ class FlareTests: XCTestCase { // given let errorMock = IAPError.paymentNotAllowed iapProviderMock.stubbedCanMakePayments = true + iapProviderMock.stubbedPurchaseWithPromotionalOffer = .failure(errorMock) // when var error: IAPError? @@ -109,7 +111,7 @@ class FlareTests: XCTestCase { iapProviderMock.invokedPurchaseParameters?.completion(.failure(errorMock)) // then - XCTAssertTrue(iapProviderMock.invokedPurchase) + XCTAssertTrue(iapProviderMock.invokedPurchaseWithPromotionalOffer) XCTAssertEqual(error, errorMock) } @@ -131,13 +133,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) } diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift index e83dc1a4d..09ba52b76 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift @@ -191,29 +191,42 @@ final class IAPProviderMock: IIAPProvider { return stubbedAsyncPurchaseWithOptions } - func promotionalOffer( - productDiscount _: StoreProductDiscount, - product _: StoreProduct, - completion _: @escaping @Sendable (Result) -> Void - ) {} - - func purchase( - product _: StoreProduct, - promotionalOffer _: PromotionalOffer?, - completion _: @escaping Closure> - ) {} - var invokedPurchaseWithPromotionalOffer = false var invokedPurchaseWithPromotionalOfferCount = 0 - var stubbedPurchaseWithPromotionalOffer: StoreTransaction! + var invokedPurchaseWithPromotionalOfferParameters: (product: StoreProduct, promotionalOffer: PromotionalOffer?)? + var invokedPurchaseWithPromotionalOfferParametersList = [(product: StoreProduct, VpromotionalOffer: PromotionalOffer?)]() + var stubbedPurchaseWithPromotionalOffer: Result? func purchase( - product _: StoreProduct, - promotionalOffer _: PromotionalOffer? - ) async throws -> StoreTransaction { + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: @escaping Closure> + ) { invokedPurchaseWithPromotionalOffer = true invokedPurchaseWithPromotionalOfferCount += 1 - return stubbedPurchaseWithPromotionalOffer + 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, *) From 60fe761eab3aef6ae268927e3d7493c015b509d2 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Tue, 16 Jan 2024 13:08:42 +0100 Subject: [PATCH 04/17] Implement eligibility checking --- Sources/Flare/Classes/Flare.swift | 5 +++ Sources/Flare/Classes/IFlare.swift | 8 +++++ .../Models/SubscriptionEligibility.swift | 12 +++++++ .../EligibilityProvider.swift | 32 +++++++++++++++++++ .../IEligibilityProvider.swift | 17 ++++++++++ .../Providers/IAPProvider/IAPProvider.swift | 14 ++++++-- .../Providers/IAPProvider/IIAPProvider.swift | 8 +++++ .../TestHelpers/Mocks/IAPProviderMock.swift | 15 +++++++++ 8 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 Sources/Flare/Classes/Models/SubscriptionEligibility.swift create mode 100644 Sources/Flare/Classes/Providers/EligibilityProvider/EligibilityProvider.swift create mode 100644 Sources/Flare/Classes/Providers/EligibilityProvider/IEligibilityProvider.swift diff --git a/Sources/Flare/Classes/Flare.swift b/Sources/Flare/Classes/Flare.swift index d9c68b7ae..d334fa07e 100644 --- a/Sources/Flare/Classes/Flare.swift +++ b/Sources/Flare/Classes/Flare.swift @@ -120,6 +120,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) diff --git a/Sources/Flare/Classes/IFlare.swift b/Sources/Flare/Classes/IFlare.swift index bcb6af2e4..6ea11619d 100644 --- a/Sources/Flare/Classes/IFlare.swift +++ b/Sources/Flare/Classes/IFlare.swift @@ -132,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. /// diff --git a/Sources/Flare/Classes/Models/SubscriptionEligibility.swift b/Sources/Flare/Classes/Models/SubscriptionEligibility.swift new file mode 100644 index 000000000..0e2295369 --- /dev/null +++ b/Sources/Flare/Classes/Models/SubscriptionEligibility.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +public enum SubscriptionEligibility: Int, Sendable { + case eligible + case nonEligible + case noOffer +} diff --git a/Sources/Flare/Classes/Providers/EligibilityProvider/EligibilityProvider.swift b/Sources/Flare/Classes/Providers/EligibilityProvider/EligibilityProvider.swift new file mode 100644 index 000000000..a985b1075 --- /dev/null +++ b/Sources/Flare/Classes/Providers/EligibilityProvider/EligibilityProvider.swift @@ -0,0 +1,32 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - EligibilityProvider + +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 215d5dbab..0cf87affa 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -19,6 +19,8 @@ final class IAPProvider: IIAPProvider { private let receiptRefreshProvider: IReceiptRefreshProvider /// The provider is responsible for refunding purchases private let refundProvider: IRefundProvider + /// + private let eligibilityProvider: IEligibilityProvider // MARK: Initialization @@ -27,7 +29,7 @@ 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. init( @@ -37,13 +39,15 @@ final class IAPProvider: IIAPProvider { receiptRefreshProvider: IReceiptRefreshProvider = ReceiptRefreshProvider(), refundProvider: IRefundProvider = RefundProvider( systemInfoProvider: SystemInfoProvider() - ) + ), + eligibilityProvider: IEligibilityProvider = EligibilityProvider() ) { self.paymentQueue = paymentQueue self.productProvider = productProvider self.purchaseProvider = purchaseProvider self.receiptRefreshProvider = receiptRefreshProvider self.refundProvider = refundProvider + self.eligibilityProvider = eligibilityProvider } // MARK: Internal @@ -161,6 +165,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) diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift index 21f55becb..e50c04acf 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift @@ -135,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. /// diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift index 09ba52b76..d2da24a00 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift @@ -251,4 +251,19 @@ final class IAPProviderMock: IIAPProvider { 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 + } } From d4fe9819e197bf96f8af636a6a4c0f1fb453f8a4 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Tue, 16 Jan 2024 13:10:01 +0100 Subject: [PATCH 05/17] Add comments to the `SubscriptionEligibility` enumeration --- Sources/Flare/Classes/Models/SubscriptionEligibility.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/Flare/Classes/Models/SubscriptionEligibility.swift b/Sources/Flare/Classes/Models/SubscriptionEligibility.swift index 0e2295369..362884ace 100644 --- a/Sources/Flare/Classes/Models/SubscriptionEligibility.swift +++ b/Sources/Flare/Classes/Models/SubscriptionEligibility.swift @@ -5,8 +5,14 @@ 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 } From f4ddb9a7c63cbbda0021543f52d284a269c162fb Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Tue, 16 Jan 2024 13:30:15 +0100 Subject: [PATCH 06/17] Implement `presentOfferCodeRedeemSheet(in:)` & `presentCodeRedemptionSheet()` methods --- Sources/Flare/Classes/Flare.swift | 16 +++++++ .../Helpers/PaymentQueue/PaymentQueue.swift | 6 +++ Sources/Flare/Classes/IFlare.swift | 16 +++++++ .../Providers/IAPProvider/IAPProvider.swift | 22 +++++++++- .../Providers/IAPProvider/IIAPProvider.swift | 16 +++++++ .../IRedeemCodeProvider.swift | 24 +++++++++++ .../RedeemCodeProvider.swift | 42 +++++++++++++++++++ .../TestHelpers/Mocks/IAPProviderMock.swift | 18 ++++++++ 8 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 Sources/Flare/Classes/Providers/RedeemCodeProvider/IRedeemCodeProvider.swift create mode 100644 Sources/Flare/Classes/Providers/RedeemCodeProvider/RedeemCodeProvider.swift diff --git a/Sources/Flare/Classes/Flare.swift b/Sources/Flare/Classes/Flare.swift index d334fa07e..5ba608314 100644 --- a/Sources/Flare/Classes/Flare.swift +++ b/Sources/Flare/Classes/Flare.swift @@ -133,5 +133,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/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 6ea11619d..48e0eef63 100644 --- a/Sources/Flare/Classes/IFlare.swift +++ b/Sources/Flare/Classes/IFlare.swift @@ -151,6 +151,22 @@ 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 } diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift index 0cf87affa..b3bb37b62 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -21,6 +21,8 @@ final class IAPProvider: IIAPProvider { private let refundProvider: IRefundProvider /// private let eligibilityProvider: IEligibilityProvider + /// + private let redeemCodeProvider: IRedeemCodeProvider // MARK: Initialization @@ -40,7 +42,8 @@ final class IAPProvider: IIAPProvider { refundProvider: IRefundProvider = RefundProvider( systemInfoProvider: SystemInfoProvider() ), - eligibilityProvider: IEligibilityProvider = EligibilityProvider() + eligibilityProvider: IEligibilityProvider = EligibilityProvider(), + redeemCodeProvider: IRedeemCodeProvider = RedeemCodeProvider() ) { self.paymentQueue = paymentQueue self.productProvider = productProvider @@ -48,6 +51,7 @@ final class IAPProvider: IIAPProvider { self.receiptRefreshProvider = receiptRefreshProvider self.refundProvider = refundProvider self.eligibilityProvider = eligibilityProvider + self.redeemCodeProvider = redeemCodeProvider } // MARK: Internal @@ -179,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 e50c04acf..3e8899936 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift @@ -154,6 +154,22 @@ 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 } 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..b81bf9612 --- /dev/null +++ b/Sources/Flare/Classes/Providers/RedeemCodeProvider/RedeemCodeProvider.swift @@ -0,0 +1,42 @@ +// +// 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) + func presentOfferCodeRedeemSheet() async throws { + let windowScene = try systemInfoProvider.currentScene + try await AppStore.presentOfferCodeRedeemSheet(in: windowScene) + } + #endif +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift index d2da24a00..1d05ea36c 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift @@ -266,4 +266,22 @@ final class IAPProviderMock: IIAPProvider { 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 + } } From 0c5482be8ae84c3626388646676c3fa43e006b1c Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Tue, 16 Jan 2024 18:42:55 +0100 Subject: [PATCH 07/17] Implement passing a configuration object - Pass an `applicationUsername` property through the `Configuration` object - Refactroing the package's dependencies --- .../Flare/Classes/DI/FlareDependencies.swift | 67 +++++++++++++++++++ .../Flare/Classes/DI/IFlareDependencies.swift | 14 ++++ Sources/Flare/Classes/Flare.swift | 33 ++++++--- .../UserDefaults/IUserDefaults.swift | 11 +++ .../UserDefaults/UserDefaults.swift | 19 ++++++ .../Flare/Classes/Models/Configuration.swift | 18 +++++ .../CacheProvider/CacheProvider.swift | 32 +++++++++ .../CacheProvider/ICacheProvider.swift | 23 +++++++ .../ConfigurationProvider.swift | 38 +++++++++++ .../IConfigurationProvider.swift | 17 +++++ .../Providers/IAPProvider/IAPProvider.swift | 16 ++--- .../PaymentProvider/PaymentProvider.swift | 6 +- .../PurchaseProvider/PurchaseProvider.swift | 11 ++- .../ReceiptRefreshProvider.swift | 6 +- .../RedeemCodeProvider.swift | 1 + Tests/FlareTests/UnitTests/FlareTests.swift | 10 ++- .../Providers/IAPProviderTests.swift | 6 +- .../Providers/PurchaseProviderTests.swift | 5 +- .../Mocks/ConfigurationProviderMock.swift | 31 +++++++++ .../Mocks/EligibilityProviderMock.swift | 23 +++++++ .../Mocks/FlareDependenciesMock.swift | 29 ++++++++ .../Mocks/RedeemCodeProvider.swift | 17 +++++ 22 files changed, 400 insertions(+), 33 deletions(-) create mode 100644 Sources/Flare/Classes/DI/FlareDependencies.swift create mode 100644 Sources/Flare/Classes/DI/IFlareDependencies.swift create mode 100644 Sources/Flare/Classes/Foundation/UserDefaults/IUserDefaults.swift create mode 100644 Sources/Flare/Classes/Foundation/UserDefaults/UserDefaults.swift create mode 100644 Sources/Flare/Classes/Models/Configuration.swift create mode 100644 Sources/Flare/Classes/Providers/CacheProvider/CacheProvider.swift create mode 100644 Sources/Flare/Classes/Providers/CacheProvider/ICacheProvider.swift create mode 100644 Sources/Flare/Classes/Providers/ConfigurationProvider/ConfigurationProvider.swift create mode 100644 Sources/Flare/Classes/Providers/ConfigurationProvider/IConfigurationProvider.swift create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Mocks/ConfigurationProviderMock.swift create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Mocks/EligibilityProviderMock.swift create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Mocks/FlareDependenciesMock.swift create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Mocks/RedeemCodeProvider.swift diff --git a/Sources/Flare/Classes/DI/FlareDependencies.swift b/Sources/Flare/Classes/DI/FlareDependencies.swift new file mode 100644 index 000000000..45ee43250 --- /dev/null +++ b/Sources/Flare/Classes/DI/FlareDependencies.swift @@ -0,0 +1,67 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Concurrency +import Foundation +import StoreKit + +final class FlareDependencies: IFlareDependencies { + 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 5ba608314..0c074fa61 100644 --- a/Sources/Flare/Classes/Flare.swift +++ b/Sources/Flare/Classes/Flare.swift @@ -13,24 +13,37 @@ 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 default `Flare` object. + public static var `default`: 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 + public static func configure(with configuration: Configuration) { + flare.configurationProvider.configure(with: configuration) + } } // MARK: IFlare diff --git a/Sources/Flare/Classes/Foundation/UserDefaults/IUserDefaults.swift b/Sources/Flare/Classes/Foundation/UserDefaults/IUserDefaults.swift new file mode 100644 index 000000000..26343c296 --- /dev/null +++ b/Sources/Flare/Classes/Foundation/UserDefaults/IUserDefaults.swift @@ -0,0 +1,11 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +protocol IUserDefaults { + func set(key: String, codable: T) + 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/Models/Configuration.swift b/Sources/Flare/Classes/Models/Configuration.swift new file mode 100644 index 000000000..90f5b2c8e --- /dev/null +++ b/Sources/Flare/Classes/Models/Configuration.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +public struct Configuration { + // MARK: Properties + + public let applicationUserName: String + + // MARK: Initialization + + public init(applicationUserName: String) { + self.applicationUserName = applicationUserName + } +} diff --git a/Sources/Flare/Classes/Providers/CacheProvider/CacheProvider.swift b/Sources/Flare/Classes/Providers/CacheProvider/CacheProvider.swift new file mode 100644 index 000000000..953108146 --- /dev/null +++ b/Sources/Flare/Classes/Providers/CacheProvider/CacheProvider.swift @@ -0,0 +1,32 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - CacheProvider + +final class CacheProvider { + // MARK: Properties + + private let userDefaults: IUserDefaults + + // MARK: Initialization + + 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..14bc88f81 --- /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..c385d6811 --- /dev/null +++ b/Sources/Flare/Classes/Providers/ConfigurationProvider/ConfigurationProvider.swift @@ -0,0 +1,38 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - ConfigurationProvider + +final class ConfigurationProvider { + // MARK: Properties + + private let cacheProvider: ICacheProvider + + // MARK: Initialization + + 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 = "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/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift index b3bb37b62..34fece832 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -35,15 +35,13 @@ final class IAPProvider: IIAPProvider { /// - receiptRefreshProvider: The provider is responsible for refreshing receipts. /// - refundProvider: The provider is responsible for refunding purchases. init( - paymentQueue: PaymentQueue = SKPaymentQueue.default(), - productProvider: IProductProvider = ProductProvider(), - purchaseProvider: IPurchaseProvider = PurchaseProvider(), - receiptRefreshProvider: IReceiptRefreshProvider = ReceiptRefreshProvider(), - refundProvider: IRefundProvider = RefundProvider( - systemInfoProvider: SystemInfoProvider() - ), - eligibilityProvider: IEligibilityProvider = EligibilityProvider(), - redeemCodeProvider: IRedeemCodeProvider = RedeemCodeProvider() + paymentQueue: PaymentQueue, + productProvider: IProductProvider, + purchaseProvider: IPurchaseProvider, + receiptRefreshProvider: IReceiptRefreshProvider, + refundProvider: IRefundProvider, + eligibilityProvider: IEligibilityProvider, + redeemCodeProvider: IRedeemCodeProvider ) { self.paymentQueue = paymentQueue self.productProvider = productProvider 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/PurchaseProvider/PurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift index 47af7a869..dd2f1c754 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 @@ -46,7 +51,7 @@ final class PurchaseProvider { completion: @escaping @MainActor (Result) -> Void ) { let payment = SKMutablePayment(product: sk1StoreProduct.product) - payment.applicationUsername = "" // TODO: + payment.applicationUsername = configurationProvider.applicationUsername payment.paymentDiscount = promotionalOffer?.signedData.skPromotionalOffer paymentProvider.add(payment: payment) { _, result in Task { 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/RedeemCodeProvider.swift b/Sources/Flare/Classes/Providers/RedeemCodeProvider/RedeemCodeProvider.swift index b81bf9612..a4301be86 100644 --- a/Sources/Flare/Classes/Providers/RedeemCodeProvider/RedeemCodeProvider.swift +++ b/Sources/Flare/Classes/Providers/RedeemCodeProvider/RedeemCodeProvider.swift @@ -34,6 +34,7 @@ extension RedeemCodeProvider: IRedeemCodeProvider { @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) diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index 274d61254..a30779bd3 100644 --- a/Tests/FlareTests/UnitTests/FlareTests.swift +++ b/Tests/FlareTests/UnitTests/FlareTests.swift @@ -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() 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/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/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 + } +} From 3652df4cf48cc33ae64e16e65fd075e5ac1c6e05 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Tue, 16 Jan 2024 19:20:23 +0100 Subject: [PATCH 08/17] Write code comments & fix typos --- Sources/Flare/Classes/Flare.swift | 4 ++++ Sources/Flare/Classes/Models/Configuration.swift | 15 ++++++++++++--- .../ConfigurationProvider.swift | 4 ++-- .../PurchaseProvider/PurchaseProvider.swift | 16 +++++++++++++--- 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/Sources/Flare/Classes/Flare.swift b/Sources/Flare/Classes/Flare.swift index 0c074fa61..41de2bf7d 100644 --- a/Sources/Flare/Classes/Flare.swift +++ b/Sources/Flare/Classes/Flare.swift @@ -41,6 +41,10 @@ public final class Flare { // MARK: Public + /// 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) } diff --git a/Sources/Flare/Classes/Models/Configuration.swift b/Sources/Flare/Classes/Models/Configuration.swift index 90f5b2c8e..d2ece895c 100644 --- a/Sources/Flare/Classes/Models/Configuration.swift +++ b/Sources/Flare/Classes/Models/Configuration.swift @@ -8,11 +8,20 @@ import Foundation public struct Configuration { // MARK: Properties - public let applicationUserName: String + // 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 - public init(applicationUserName: String) { - self.applicationUserName = applicationUserName + /// 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/Providers/ConfigurationProvider/ConfigurationProvider.swift b/Sources/Flare/Classes/Providers/ConfigurationProvider/ConfigurationProvider.swift index c385d6811..f721ab0e1 100644 --- a/Sources/Flare/Classes/Providers/ConfigurationProvider/ConfigurationProvider.swift +++ b/Sources/Flare/Classes/Providers/ConfigurationProvider/ConfigurationProvider.swift @@ -27,12 +27,12 @@ extension ConfigurationProvider: IConfigurationProvider { } func configure(with configuration: Configuration) { - cacheProvider.write(key: .applicationUsername, value: configuration.applicationUserName) + cacheProvider.write(key: .applicationUsername, value: configuration.applicationUsername) } } // MARK: - Constants private extension String { - static let applicationUsername = "application_username" + static let applicationUsername = "flare.configuration.application_username" } diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift index dd2f1c754..ae5703cad 100644 --- a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift @@ -87,12 +87,22 @@ final class PurchaseProvider { } }, asyncMethod: { var options: Set = options ?? [] - if let promotionalOffer { - try options.insert(promotionalOffer.signedData.promotionalOffer) - } + 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 From 47587d9f5784ecc780782de1c77c9bb5321e73d8 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Tue, 16 Jan 2024 19:23:39 +0100 Subject: [PATCH 09/17] Rename the `default` property to `shared` --- Sources/Flare/Classes/Flare.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Flare/Classes/Flare.swift b/Sources/Flare/Classes/Flare.swift index 41de2bf7d..c1db74c81 100644 --- a/Sources/Flare/Classes/Flare.swift +++ b/Sources/Flare/Classes/Flare.swift @@ -24,8 +24,8 @@ public final class Flare { /// The singleton instance. private static let flare: Flare = .init() - /// Returns a default `Flare` object. - public static var `default`: IFlare { flare } + /// Returns a shared `Flare` object. + public static var shared: IFlare { flare } // MARK: Initialization From 554ac488d53b746d4913170c5fb2be2039e8878d Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Wed, 17 Jan 2024 08:01:43 +0100 Subject: [PATCH 10/17] Update `CHANGELOG.md` --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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). From faa4dc730ca88da60b2073e40e9ed5f69f80844c Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Wed, 17 Jan 2024 11:21:07 +0100 Subject: [PATCH 11/17] Implement unit tests --- Tests/FlareTests/UnitTests/FlareTests.swift | 13 ++ .../ConfigurationProviderTests.swift | 67 ++++++++ .../Fakes/Configuration+Fake.swift | 13 ++ .../TestHelpers/Mocks/CacheProviderMock.swift | 35 ++++ .../Mocks/CacheProviderTests.swift | 61 +++++++ .../TestHelpers/Mocks/UserDefaultsMock.swift | 35 ++++ Tests/IntegrationTests/Flare.storekit | 145 +++++++++++++---- .../Tests/EligibilityProviderTests.swift | 50 ++++++ .../Tests/IAPProviderTests.swift | 151 ------------------ .../Tests/ProductProviderHelper.swift | 35 ++-- .../Tests/ProductProviderTests.swift | 59 ------- .../Tests/PurchaseProviderTests.swift | 90 ----------- 12 files changed, 411 insertions(+), 343 deletions(-) create mode 100644 Tests/FlareTests/UnitTests/Providers/ConfigurationProviderTests.swift create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Fakes/Configuration+Fake.swift create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Mocks/CacheProviderMock.swift create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Mocks/CacheProviderTests.swift create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Mocks/UserDefaultsMock.swift create mode 100644 Tests/IntegrationTests/Tests/EligibilityProviderTests.swift delete mode 100644 Tests/IntegrationTests/Tests/IAPProviderTests.swift delete mode 100644 Tests/IntegrationTests/Tests/ProductProviderTests.swift delete mode 100644 Tests/IntegrationTests/Tests/PurchaseProviderTests.swift diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index a30779bd3..87ef3300a 100644 --- a/Tests/FlareTests/UnitTests/FlareTests.swift +++ b/Tests/FlareTests/UnitTests/FlareTests.swift @@ -223,6 +223,19 @@ class FlareTests: XCTestCase { // then XCTAssertTrue(iapProviderMock.invokedAddTransactionObserver) } + + @available(iOS 15.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/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/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/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..a8767bfa4 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,124 @@ } ], "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" : "0.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "138CEE33", + "introductoryOffer" : { + "internalID" : "970CA16D", + "paymentMode" : "free", + "subscriptionPeriod" : "P1M" + }, + "localizations" : [ + { + "description" : "", + "displayName" : "", + "locale" : "en_US" + } + ], + "productID" : "subscription_1", + "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" : "subscription_2", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Subscription Without Offers", + "subscriptionGroupID" : "C3C61FEC", + "type" : "RecurringSubscription" + } + ] + } ], "version" : { - "major" : 2, + "major" : 3, "minor" : 0 } } diff --git a/Tests/IntegrationTests/Tests/EligibilityProviderTests.swift b/Tests/IntegrationTests/Tests/EligibilityProviderTests.swift new file mode 100644 index 000000000..103584948 --- /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, *) +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.subscriptionsWithOffers.randomElement()! + + // when + let result = try await sut.checkEligibility(products: [StoreProduct(product: product)]) + + // then + XCTAssertEqual(result[product.id], .eligible) + } +} 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 index 2deb3d05b..8bf11220d 100644 --- a/Tests/IntegrationTests/Tests/ProductProviderHelper.swift +++ b/Tests/IntegrationTests/Tests/ProductProviderHelper.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import StoreKit @@ -15,11 +15,24 @@ enum ProductProviderHelper { } } -// static var subscriptions: [StoreKit.Product] { -// get async throws { -// try await StoreKit.Product.products(for: [.testSubscription1ID, .testSubscription2ID]) -// } -// } + static var subscriptions: [StoreKit.Product] { + get async throws { + try await subscriptionsWithOffers + subscriptionsWithoutOffers + } + } + + static var subscriptionsWithOffers: [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 all: [StoreKit.Product] { // get async throws { @@ -34,11 +47,11 @@ enum ProductProviderHelper { // 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" + /// The subscription's id with introductionary offer + static let subscription1ID = "subscription_1" + + /// The subscription's id without introductionary offer + static let subscription2ID = "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 -// } From 121bd94f109a4ebc79c94a706cc587facdfea92d Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Wed, 17 Jan 2024 14:26:15 +0100 Subject: [PATCH 12/17] Implement tests --- Sources/Flare/Classes/Models/IAPError.swift | 2 + .../Internal/SK1StoreProductDiscount.swift | 2 +- .../Classes/Models/SubscriptionPeriod.swift | 13 ++ Tests/FlareTests/UnitTests/FlareTests.swift | 2 +- .../Models/PromotionalOfferTests.swift | 56 ++++++ .../TestHelpers/Extensions/String+Data.swift | 12 ++ Tests/IntegrationTests/Flare.storekit | 106 ++++++++++- .../Helpers/Extensions/AsyncSequence+.swift | 16 ++ .../Providers}/ProductProviderHelper.swift | 29 ++- .../StoreSessionTestCase.swift | 35 +++- .../Tests/EligibilityProviderTests.swift | 4 +- .../Tests/StoreProductTests.swift | 173 ++++++++++++++++++ 12 files changed, 426 insertions(+), 24 deletions(-) create mode 100644 Tests/FlareTests/UnitTests/Models/PromotionalOfferTests.swift create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Extensions/String+Data.swift create mode 100644 Tests/IntegrationTests/Helpers/Extensions/AsyncSequence+.swift rename Tests/IntegrationTests/{Tests => Helpers/Providers}/ProductProviderHelper.swift (55%) create mode 100644 Tests/IntegrationTests/Tests/StoreProductTests.swift diff --git a/Sources/Flare/Classes/Models/IAPError.swift b/Sources/Flare/Classes/Models/IAPError.swift index db0f5541c..80de297b9 100644 --- a/Sources/Flare/Classes/Models/IAPError.swift +++ b/Sources/Flare/Classes/Models/IAPError.swift @@ -127,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/SK1StoreProductDiscount.swift b/Sources/Flare/Classes/Models/Internal/SK1StoreProductDiscount.swift index 559810d58..263bee9d2 100644 --- a/Sources/Flare/Classes/Models/Internal/SK1StoreProductDiscount.swift +++ b/Sources/Flare/Classes/Models/Internal/SK1StoreProductDiscount.swift @@ -50,7 +50,7 @@ struct SK1StoreProductDiscount: IStoreProductDiscount { self.productDiscount = productDiscount offerIdentifier = productDiscount.identifier - currencyCode = "" + currencyCode = productDiscount.priceLocale.currencyCodeID price = productDiscount.price as Decimal self.paymentMode = paymentMode self.subscriptionPeriod = subscriptionPeriod diff --git a/Sources/Flare/Classes/Models/SubscriptionPeriod.swift b/Sources/Flare/Classes/Models/SubscriptionPeriod.swift index c9660e1d4..3a8ef32cc 100644 --- a/Sources/Flare/Classes/Models/SubscriptionPeriod.swift +++ b/Sources/Flare/Classes/Models/SubscriptionPeriod.swift @@ -41,6 +41,19 @@ public final class SubscriptionPeriod: NSObject, Sendable { 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 diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index 87ef3300a..6998e78bf 100644 --- a/Tests/FlareTests/UnitTests/FlareTests.swift +++ b/Tests/FlareTests/UnitTests/FlareTests.swift @@ -224,7 +224,7 @@ class FlareTests: XCTestCase { XCTAssertTrue(iapProviderMock.invokedAddTransactionObserver) } - @available(iOS 15.0, *) + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) func test_thatFlareChecksEligibility() async throws { // given iapProviderMock.stubbedCheckEligibility = [.productID: .eligible] 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/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/IntegrationTests/Flare.storekit b/Tests/IntegrationTests/Flare.storekit index a8767bfa4..23d0dd415 100644 --- a/Tests/IntegrationTests/Flare.storekit +++ b/Tests/IntegrationTests/Flare.storekit @@ -87,13 +87,15 @@ "codeOffers" : [ ], - "displayPrice" : "0.99", + "displayPrice" : "1.99", "familyShareable" : false, "groupNumber" : 1, "internalID" : "138CEE33", "introductoryOffer" : { + "displayPrice" : "0.99", "internalID" : "970CA16D", - "paymentMode" : "free", + "numberOfPeriods" : 1, + "paymentMode" : "payAsYouGo", "subscriptionPeriod" : "P1M" }, "localizations" : [ @@ -103,7 +105,7 @@ "locale" : "en_US" } ], - "productID" : "subscription_1", + "productID" : "com.flare.monthly_1.99_week_intro", "recurringSubscriptionPeriod" : "P1M", "referenceName" : "Subscription with Introductory Offer", "subscriptionGroupID" : "C3C61FEC", @@ -128,11 +130,107 @@ "locale" : "en_US" } ], - "productID" : "subscription_2", + "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" } ] } 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/Tests/ProductProviderHelper.swift b/Tests/IntegrationTests/Helpers/Providers/ProductProviderHelper.swift similarity index 55% rename from Tests/IntegrationTests/Tests/ProductProviderHelper.swift rename to Tests/IntegrationTests/Helpers/Providers/ProductProviderHelper.swift index 8bf11220d..4e35afd6d 100644 --- a/Tests/IntegrationTests/Tests/ProductProviderHelper.swift +++ b/Tests/IntegrationTests/Helpers/Providers/ProductProviderHelper.swift @@ -17,11 +17,11 @@ enum ProductProviderHelper { static var subscriptions: [StoreKit.Product] { get async throws { - try await subscriptionsWithOffers + subscriptionsWithoutOffers + try await subscriptionsWithIntroductoryOffer + subscriptionsWithoutOffers + subscriptonsWithOffers } } - static var subscriptionsWithOffers: [StoreKit.Product] { + static var subscriptionsWithIntroductoryOffer: [StoreKit.Product] { get async throws { try await StoreKit.Product.products(for: [.subscription1ID]) } @@ -33,15 +33,11 @@ enum ProductProviderHelper { } } -// -// static var all: [StoreKit.Product] { -// get async throws { -// let purchases = try await self.purchases -// let subscriptions = try await self.subscriptions -// -// return purchases + subscriptions -// } -// } + static var subscriptonsWithOffers: [StoreKit.Product] { + get async throws { + try await StoreKit.Product.products(for: [.subscription3ID]) + } + } } // MARK: - Constants @@ -49,9 +45,12 @@ enum ProductProviderHelper { private extension String { static let testNonConsumableID = "com.flare.test_non_consumable_purchase_1" - /// The subscription's id with introductionary offer - static let subscription1ID = "subscription_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 without introductionary offer - static let subscription2ID = "subscription_2" + /// 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..a83c6f220 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,33 @@ 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 productIdentifier: 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 == productIdentifier + } + .extractValues() + + return try XCTUnwrap(transactions.first) + } +} diff --git a/Tests/IntegrationTests/Tests/EligibilityProviderTests.swift b/Tests/IntegrationTests/Tests/EligibilityProviderTests.swift index 103584948..ec7e48152 100644 --- a/Tests/IntegrationTests/Tests/EligibilityProviderTests.swift +++ b/Tests/IntegrationTests/Tests/EligibilityProviderTests.swift @@ -6,7 +6,7 @@ @testable import Flare import XCTest -@available(iOS 15.0, *) +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) final class EligibilityProviderTests: StoreSessionTestCase { // MARK: Properties @@ -39,7 +39,7 @@ final class EligibilityProviderTests: StoreSessionTestCase { func test_thatProviderReturnsEligible_whenProductHasIntroductoryOffer() async throws { // given - let product = try await ProductProviderHelper.subscriptionsWithOffers.randomElement()! + let product = try await ProductProviderHelper.subscriptionsWithIntroductoryOffer.randomElement()! // when let result = try await sut.checkEligibility(products: [StoreProduct(product: product)]) diff --git a/Tests/IntegrationTests/Tests/StoreProductTests.swift b/Tests/IntegrationTests/Tests/StoreProductTests.swift new file mode 100644 index 000000000..4c61850f8 --- /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: .second) + #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 second: CGFloat = 1.0 +} From 5cbfd0770bdd273e1989e475320256da8b98b37d Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Wed, 17 Jan 2024 19:18:56 +0100 Subject: [PATCH 13/17] Increase expectation duration for a test --- Tests/IntegrationTests/Tests/StoreProductTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/IntegrationTests/Tests/StoreProductTests.swift b/Tests/IntegrationTests/Tests/StoreProductTests.swift index 4c61850f8..91432b7e2 100644 --- a/Tests/IntegrationTests/Tests/StoreProductTests.swift +++ b/Tests/IntegrationTests/Tests/StoreProductTests.swift @@ -48,7 +48,7 @@ final class StoreProductTests: StoreSessionTestCase { #if swift(>=5.9) await fulfillment(of: [expectation]) #else - wait(for: [expectation], timeout: .second) + wait(for: [expectation], timeout: .seconds) #endif // then @@ -169,5 +169,5 @@ private extension String { } private extension TimeInterval { - static let second: CGFloat = 1.0 + static let seconds: CGFloat = 60.0 } From 61b1f24909a32bce4405c1c5ee22edee8bf55050 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Wed, 17 Jan 2024 20:12:28 +0100 Subject: [PATCH 14/17] Update the documentation --- README.md | 5 +- .../Flare.docc/Articles/perform-purchase.md | 14 +-- .../Flare.docc/Articles/promotional-offers.md | 86 +++++++++++++++++++ .../Flare.docc/Articles/refund-purchase.md | 2 +- .../Flare.docc/Articles/restore-purchase.md | 4 +- Sources/Flare/Flare.docc/Flare.md | 9 +- 6 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 Sources/Flare/Flare.docc/Articles/promotional-offers.md diff --git a/README.md b/README.md index c1038168d..b294498c9 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 Test & Integration Coverage ## Documentation Check out [flare documentation](https://space-code.github.io/flare/documentation/flare/). 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. - - - +- From 94503d797d4b3c4dff1420aae6373b99d462298e Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Thu, 18 Jan 2024 10:04:32 +0100 Subject: [PATCH 15/17] Fix flacky tests --- .../Models/Internal/ProductsRequest.swift | 33 +++++++++++++++++++ .../Internal/Protocols/ISKRequest.swift | 10 ++++++ .../ProductProvider/ProductProvider.swift | 19 ++++++++--- 3 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 Sources/Flare/Classes/Models/Internal/ProductsRequest.swift create mode 100644 Sources/Flare/Classes/Models/Internal/Protocols/ISKRequest.swift diff --git a/Sources/Flare/Classes/Models/Internal/ProductsRequest.swift b/Sources/Flare/Classes/Models/Internal/ProductsRequest.swift new file mode 100644 index 000000000..bc838afad --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/ProductsRequest.swift @@ -0,0 +1,33 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +final class ProductsRequest: ISKRequest { + // MARK: Properties + + private let request: SKRequest + + var id: String { request.id } + + // MARK: Initialization + + 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/ISKRequest.swift b/Sources/Flare/Classes/Models/Internal/Protocols/ISKRequest.swift new file mode 100644 index 000000000..1c9baed59 --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/Protocols/ISKRequest.swift @@ -0,0 +1,10 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +protocol ISKRequest: Hashable { + var id: String { get } +} 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) + } +} From c705059e3d9e894c22abf3de0c4aee9056393286 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Thu, 18 Jan 2024 12:12:35 +0100 Subject: [PATCH 16/17] Add an integration test --- .../StoreSessionTestCase.swift | 27 +++++++++- Tests/IntegrationTests/Tests/FlareTests.swift | 50 ++++++++++++++++++- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/Tests/IntegrationTests/Helpers/StoreSessionTestCase/StoreSessionTestCase.swift b/Tests/IntegrationTests/Helpers/StoreSessionTestCase/StoreSessionTestCase.swift index a83c6f220..d0923294a 100644 --- a/Tests/IntegrationTests/Helpers/StoreSessionTestCase/StoreSessionTestCase.swift +++ b/Tests/IntegrationTests/Helpers/StoreSessionTestCase/StoreSessionTestCase.swift @@ -44,7 +44,7 @@ extension StoreSessionTestCase { } @available(iOS 15.2, tvOS 15.2, macOS 12.1, watchOS 8.3, *) - func findTransaction(for productIdentifier: String) async throws -> Transaction { + func findTransaction(for productID: String) async throws -> Transaction { let transactions: [Transaction] = await Transaction.currentEntitlements .compactMap { result in switch result { @@ -55,10 +55,33 @@ extension StoreSessionTestCase { } } .filter { (transaction: Transaction) in - transaction.productID == productIdentifier + 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/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( From 1bc7a073632c792af14fbd71eb0b809117950b08 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Thu, 18 Jan 2024 13:14:11 +0100 Subject: [PATCH 17/17] Add code comments --- README.md | 2 +- .../Flare/Classes/DI/FlareDependencies.swift | 3 +++ .../UserDefaults/IUserDefaults.swift | 13 ++++++++++ .../Flare/Classes/Models/DiscountType.swift | 1 + .../Models/Internal/ProductsRequest.swift | 6 +++++ .../Internal/Protocols/ISKProduct.swift | 5 ++-- .../Internal/Protocols/ISKRequest.swift | 2 ++ .../Classes/Models/PromotionalOffer.swift | 26 +++++++++++++++++++ .../CacheProvider/CacheProvider.swift | 5 ++++ .../CacheProvider/ICacheProvider.swift | 2 +- .../ConfigurationProvider.swift | 6 +++++ .../EligibilityProvider.swift | 1 + .../Providers/IAPProvider/IAPProvider.swift | 6 +++-- 13 files changed, 72 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b294498c9..a72bf09c7 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Flare is a framework written in Swift that makes it easy for you to work with in - [x] Support Subscription Purchase - [x] Support Promotional & Introductory Offers - [x] iOS, tvOS, watchOS, macOS, and visionOS compatible -- [x] Complete Unit Test & Integration Coverage +- [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 index 45ee43250..5f50073af 100644 --- a/Sources/Flare/Classes/DI/FlareDependencies.swift +++ b/Sources/Flare/Classes/DI/FlareDependencies.swift @@ -7,7 +7,10 @@ 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, diff --git a/Sources/Flare/Classes/Foundation/UserDefaults/IUserDefaults.swift b/Sources/Flare/Classes/Foundation/UserDefaults/IUserDefaults.swift index 26343c296..dfbd224b4 100644 --- a/Sources/Flare/Classes/Foundation/UserDefaults/IUserDefaults.swift +++ b/Sources/Flare/Classes/Foundation/UserDefaults/IUserDefaults.swift @@ -5,7 +5,20 @@ 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/Models/DiscountType.swift b/Sources/Flare/Classes/Models/DiscountType.swift index 8cd51df69..37f49187f 100644 --- a/Sources/Flare/Classes/Models/DiscountType.swift +++ b/Sources/Flare/Classes/Models/DiscountType.swift @@ -8,6 +8,7 @@ import StoreKit // MARK: - DiscountType +/// The type of discount offer. public enum DiscountType: Int, Sendable { /// Introductory offer case introductory = 0 diff --git a/Sources/Flare/Classes/Models/Internal/ProductsRequest.swift b/Sources/Flare/Classes/Models/Internal/ProductsRequest.swift index bc838afad..21be0b315 100644 --- a/Sources/Flare/Classes/Models/Internal/ProductsRequest.swift +++ b/Sources/Flare/Classes/Models/Internal/ProductsRequest.swift @@ -6,15 +6,21 @@ 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 } diff --git a/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift b/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift index 7e4dcdad1..d649481ef 100644 --- a/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift +++ b/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift @@ -34,11 +34,12 @@ protocol ISKProduct { /// The subscription period for the product, if applicable. var subscriptionPeriod: SubscriptionPeriod? { get } - /// <#Description#> + /// The details of an introductory offer for an auto-renewable subscription. var introductoryDiscount: StoreProductDiscount? { get } - /// <#Description#> + /// 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 index 1c9baed59..5ec7b4fc1 100644 --- a/Sources/Flare/Classes/Models/Internal/Protocols/ISKRequest.swift +++ b/Sources/Flare/Classes/Models/Internal/Protocols/ISKRequest.swift @@ -5,6 +5,8 @@ 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/PromotionalOffer.swift b/Sources/Flare/Classes/Models/PromotionalOffer.swift index b17fbb1ca..c5a279c5c 100644 --- a/Sources/Flare/Classes/Models/PromotionalOffer.swift +++ b/Sources/Flare/Classes/Models/PromotionalOffer.swift @@ -8,14 +8,22 @@ 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 @@ -25,15 +33,30 @@ public final class PromotionalOffer: NSObject, Sendable { // 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 @@ -47,6 +70,9 @@ public extension PromotionalOffer { // 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, diff --git a/Sources/Flare/Classes/Providers/CacheProvider/CacheProvider.swift b/Sources/Flare/Classes/Providers/CacheProvider/CacheProvider.swift index 953108146..a28ef627f 100644 --- a/Sources/Flare/Classes/Providers/CacheProvider/CacheProvider.swift +++ b/Sources/Flare/Classes/Providers/CacheProvider/CacheProvider.swift @@ -7,13 +7,18 @@ 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 } diff --git a/Sources/Flare/Classes/Providers/CacheProvider/ICacheProvider.swift b/Sources/Flare/Classes/Providers/CacheProvider/ICacheProvider.swift index 14bc88f81..9c5480dec 100644 --- a/Sources/Flare/Classes/Providers/CacheProvider/ICacheProvider.swift +++ b/Sources/Flare/Classes/Providers/CacheProvider/ICacheProvider.swift @@ -5,7 +5,7 @@ import Foundation -// Type for a cache provider that supports reading and writing Codable values. +/// 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. /// diff --git a/Sources/Flare/Classes/Providers/ConfigurationProvider/ConfigurationProvider.swift b/Sources/Flare/Classes/Providers/ConfigurationProvider/ConfigurationProvider.swift index f721ab0e1..08d3e7361 100644 --- a/Sources/Flare/Classes/Providers/ConfigurationProvider/ConfigurationProvider.swift +++ b/Sources/Flare/Classes/Providers/ConfigurationProvider/ConfigurationProvider.swift @@ -7,13 +7,19 @@ 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 } diff --git a/Sources/Flare/Classes/Providers/EligibilityProvider/EligibilityProvider.swift b/Sources/Flare/Classes/Providers/EligibilityProvider/EligibilityProvider.swift index a985b1075..ddaa1be78 100644 --- a/Sources/Flare/Classes/Providers/EligibilityProvider/EligibilityProvider.swift +++ b/Sources/Flare/Classes/Providers/EligibilityProvider/EligibilityProvider.swift @@ -7,6 +7,7 @@ import Foundation // MARK: - EligibilityProvider +/// A class that provides eligibility checking functionality. final class EligibilityProvider {} // MARK: IEligibilityProvider diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift index 34fece832..8850f3a19 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -19,9 +19,9 @@ 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 @@ -34,6 +34,8 @@ final class IAPProvider: IIAPProvider { /// - 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, productProvider: IProductProvider,