diff --git a/.swiftformat b/.swiftformat index 2cb62753f..a4294246c 100644 --- a/.swiftformat +++ b/.swiftformat @@ -36,8 +36,8 @@ --enable redundantSelf --enable redundantVoidReturnType --enable semicolons ---enable sortedImports ---enable sortedSwitchCases +--enable sortImports +--enable sortSwitchCases --enable spaceAroundBraces --enable spaceAroundBrackets --enable spaceAroundComments diff --git a/.swiftlint.yml b/.swiftlint.yml index b5956417d..ca37591b9 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,6 +1,7 @@ excluded: - Tests - Package.swift + - Package@swift-5.7.swift - .build # Rules diff --git a/CHANGELOG.md b/CHANGELOG.md index 477b2241c..c6f5e99bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,18 @@ # Change Log All notable changes to this project will be documented in this file. -#### 2.x Releases -- `2.0.x` Releases - [2.0.0](#200) +## [Unreleased] -## [Unreleased]() +## Added +- Implement a refund for purchases + - Added in Pull Request [#6](https://github.com/space-code/flare/pull/6). -#### Added - Added `visionOS` to list of supported platforms - Added in Pull Request [#5](https://github.com/space-code/flare/pull/5). +#### 2.x Releases +- `2.0.x` Releases - [2.0.0](#200) + ## [2.0.0](https://github.com/space-code/flare/releases/tag/2.0.0) Released on 2023-09-13. diff --git a/Documentation/Resources/flare.png b/Documentation/Resources/flare.png index 2ac351475..5a1ac4071 100644 Binary files a/Documentation/Resources/flare.png and b/Documentation/Resources/flare.png differ diff --git a/Documentation/Usage.md b/Documentation/Usage.md index d9a400b4e..fc6e788c1 100644 --- a/Documentation/Usage.md +++ b/Documentation/Usage.md @@ -27,6 +27,7 @@ Flare provides an elegant interface for In-App Purchases, supporting non-consuma - `IProductProvider` is a component of `Flare` that helps managing the products or services available for purchase within your app. - `IReceiptRefreshProvider` is responsible for refreshing and managing receipt associated with in-app purchases. - `IAppStoreReceiptProvider` manages and provides access to the app's receipt, which contains a record of all in-app purchases made by the user. +- `IRefundProvider` is responsible for refunding purchases. This API is available starting from iOS 15. ## In-App Purchases @@ -137,6 +138,18 @@ Flare.default.addTransactionObserver { result in Flare.default.removeTransactionObserver() ``` +### Refunding Purchase + +Starting with iOS 15, `Flare` now includes support for refunding purchases as part of `StoreKit 2`. Under the hood, 'Flare' obtains the active window scene and displays the sheets on it. You can read more about the refunding process in the official Apple documentation [here](https://developer.apple.com/documentation/storekit/transaction/3803220-beginrefundrequest/). + +```swift +do { + let status = try await Flare.default.beginRefundRequest(productID: "product_id") +} catch { + debugPrint("An error occurred while refunding purchase: \(error.localizedDescription)") +} +``` + ## Handling Errors ### IAPError @@ -163,6 +176,8 @@ public enum IAPError: Swift.Error { case with(error: Swift.Error) /// The App Store receipt wasn't found. case receiptNotFound + /// The refund error. + case refund(error: RefundError) /// The unknown error occurred. case unknown } diff --git a/Mintfile b/Mintfile index 1f32d3389..e2cdefabc 100644 --- a/Mintfile +++ b/Mintfile @@ -1,2 +1,2 @@ -nicklockwood/SwiftFormat@0.47.12 -realm/SwiftLint@0.47.1 \ No newline at end of file +nicklockwood/SwiftFormat@0.52.7 +realm/SwiftLint@0.53.0 \ No newline at end of file diff --git a/Package.resolved b/Package.resolved index 24dde42a0..ff7b43099 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,16 +1,23 @@ { - "object": { - "pins": [ - { - "package": "Concurrency", - "repositoryURL": "https://github.com/space-code/concurrency", - "state": { - "branch": null, - "revision": "f9611694f77f64e43d9467a16b2f5212cd04099b", - "version": "0.0.1" - } + "pins" : [ + { + "identity" : "concurrency", + "kind" : "remoteSourceControl", + "location" : "https://github.com/space-code/concurrency", + "state" : { + "revision" : "f9611694f77f64e43d9467a16b2f5212cd04099b", + "version" : "0.0.1" } - ] - }, - "version": 1 + }, + { + "identity" : "objects-factory", + "kind" : "remoteSourceControl", + "location" : "https://github.com/space-code/objects-factory.git", + "state" : { + "revision" : "be016801934d18d91e33845e5e5b9a12617698b0", + "version" : "1.0.0" + } + } + ], + "version" : 2 } diff --git a/Package.swift b/Package.swift index 3644a161d..1a48e0820 100644 --- a/Package.swift +++ b/Package.swift @@ -1,9 +1,11 @@ -// swift-tools-version: 5.5 +// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. // swiftlint:disable all import PackageDescription +let visionOSSetting: SwiftSetting = .define("VISION_OS", .when(platforms: [.visionOS])) + let package = Package( name: "Flare", platforms: [ @@ -11,24 +13,28 @@ let package = Package( .iOS(.v13), .watchOS(.v7), .tvOS(.v13), + .visionOS(.v1), ], products: [ .library(name: "Flare", targets: ["Flare"]), ], dependencies: [ .package(url: "https://github.com/space-code/concurrency.git", .upToNextMajor(from: "0.0.1")), + .package(url: "https://github.com/space-code/objects-factory.git", .upToNextMajor(from: "1.0.0")), ], targets: [ .target( name: "Flare", dependencies: [ .product(name: "Concurrency", package: "concurrency"), - ] + ], + swiftSettings: [visionOSSetting] ), .testTarget( name: "FlareTests", dependencies: [ "Flare", + .product(name: "ObjectsFactory", package: "objects-factory"), .product(name: "TestConcurrency", package: "concurrency"), ] ), diff --git a/Package@swift-5.9.swift b/Package@swift-5.7.swift similarity index 80% rename from Package@swift-5.9.swift rename to Package@swift-5.7.swift index bb990c597..f933cf240 100644 --- a/Package@swift-5.9.swift +++ b/Package@swift-5.7.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 5.7 // The swift-tools-version declares the minimum version of Swift required to build this package. // swiftlint:disable all @@ -11,13 +11,13 @@ let package = Package( .iOS(.v13), .watchOS(.v7), .tvOS(.v13), - .visionOS(.v1), ], products: [ .library(name: "Flare", targets: ["Flare"]), ], dependencies: [ .package(url: "https://github.com/space-code/concurrency.git", .upToNextMajor(from: "0.0.1")), + .package(url: "https://github.com/space-code/objects-factory.git", .upToNextMajor(from: "1.0.0")), ], targets: [ .target( @@ -30,6 +30,7 @@ let package = Package( name: "FlareTests", dependencies: [ "Flare", + .product(name: "ObjectsFactory", package: "objects-factory"), .product(name: "TestConcurrency", package: "concurrency"), ] ), diff --git a/README.md b/README.md index b1baded65..4505f73b7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Liscence Platform -Swift5.5 +Swift5.7 CI CodeCov @@ -36,7 +36,7 @@ Check out [flare documentation](https://github.com/space-code/flare/blob/main/Do ## Requirements - iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 7.0+ / visionOS 1.0+ - Xcode 14.0 -- Swift 5.5 +- Swift 5.7 ## Installation ### Swift Package Manager diff --git a/Sources/Flare/Classes/Helpers/ProcessInfo/ProcessInfo+.swift b/Sources/Flare/Classes/Helpers/ProcessInfo/ProcessInfo+.swift new file mode 100644 index 000000000..155c22950 --- /dev/null +++ b/Sources/Flare/Classes/Helpers/ProcessInfo/ProcessInfo+.swift @@ -0,0 +1,29 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +#if DEBUG + extension ProcessInfo { + static var isRunningUnitTests: Bool { + self[.XCTestConfigurationFile] != nil + } + } + + // MARK: - Extensions + + extension ProcessInfo { + static subscript(key: String) -> String? { + processInfo.environment[key] + } + } + + // MARK: - Constants + + private extension String { + static let XCTestConfigurationFile = "XCTestConfigurationFilePath" + } + +#endif diff --git a/Sources/Flare/Classes/Helpers/ScenesHolder/IScenesHolder.swift b/Sources/Flare/Classes/Helpers/ScenesHolder/IScenesHolder.swift new file mode 100644 index 000000000..118d26a5d --- /dev/null +++ b/Sources/Flare/Classes/Helpers/ScenesHolder/IScenesHolder.swift @@ -0,0 +1,22 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if canImport(UIKit) + import UIKit +#endif + +// MARK: - IScenesHolder + +/// A type that holds all connected scenes. +protocol IScenesHolder { + #if os(iOS) || VISION_OS + /// The scenes that are connected to the app. + var connectedScenes: Set { get } + #endif +} + +#if os(iOS) || VISION_OS + extension UIApplication: IScenesHolder {} +#endif diff --git a/Sources/Flare/Classes/Models/IAPError.swift b/Sources/Flare/Classes/Models/IAPError.swift index 65fae3136..2977011e5 100644 --- a/Sources/Flare/Classes/Models/IAPError.swift +++ b/Sources/Flare/Classes/Models/IAPError.swift @@ -26,6 +26,10 @@ public enum IAPError: Swift.Error { case with(error: Swift.Error) /// The App Store receipt wasn't found. case receiptNotFound + /// The transaction wasn't found. + case transactionNotFound(productID: String) + /// The refund error. + case refund(error: RefundError) /// The unknown error occurred. case unknown } @@ -63,6 +67,7 @@ extension IAPError { // MARK: Equatable +// swiftlint:disable cyclomatic_complexity extension IAPError: Equatable { public static func == (lhs: IAPError, rhs: IAPError) -> Bool { switch (lhs, rhs) { @@ -82,6 +87,8 @@ extension IAPError: Equatable { return (lhs as NSError) == (rhs as NSError) case (.receiptNotFound, .receiptNotFound): return true + case let (.refund(lhs), .refund(rhs)): + return lhs == rhs case (.unknown, .unknown): return true default: @@ -89,3 +96,5 @@ extension IAPError: Equatable { } } } + +// swiftlint:enable cyclomatic_complexity diff --git a/Sources/Flare/Classes/Models/RefundError.swift b/Sources/Flare/Classes/Models/RefundError.swift new file mode 100644 index 000000000..b7577599a --- /dev/null +++ b/Sources/Flare/Classes/Models/RefundError.swift @@ -0,0 +1,14 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +/// It encompasses all types of refund errors. +public enum RefundError: Error, Equatable { + /// The duplicate refund request. + case duplicateRequest + /// The refund request failed. + case failed +} diff --git a/Sources/Flare/Classes/Models/RefundRequestStatus.swift b/Sources/Flare/Classes/Models/RefundRequestStatus.swift new file mode 100644 index 000000000..e7c69cf8f --- /dev/null +++ b/Sources/Flare/Classes/Models/RefundRequestStatus.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +/// It encompasses all refund request states. +public enum RefundRequestStatus: Sendable { + /// A user cancelled the refund request. + case userCancelled + /// The request completed successfully. + case success + /// The refund request failed with an error. + case failed(error: Error) + /// The unknown error occurred. + case unknown +} diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift index 001843387..85fc9be86 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -12,6 +12,7 @@ final class IAPProvider: IIAPProvider { private let productProvider: IProductProvider private let paymentProvider: IPaymentProvider private let receiptRefreshProvider: IReceiptRefreshProvider + private let refundProvider: IRefundProvider // MARK: Initialization @@ -19,12 +20,16 @@ final class IAPProvider: IIAPProvider { paymentQueue: PaymentQueue = SKPaymentQueue.default(), productProvider: IProductProvider = ProductProvider(), paymentProvider: IPaymentProvider = PaymentProvider(), - receiptRefreshProvider: IReceiptRefreshProvider = ReceiptRefreshProvider() + receiptRefreshProvider: IReceiptRefreshProvider = ReceiptRefreshProvider(), + refundProvider: IRefundProvider = RefundProvider( + systemInfoProvider: SystemInfoProvider() + ) ) { self.paymentQueue = paymentQueue self.productProvider = productProvider self.paymentProvider = paymentProvider self.receiptRefreshProvider = receiptRefreshProvider + self.refundProvider = refundProvider } // MARK: Internal @@ -124,4 +129,14 @@ final class IAPProvider: IIAPProvider { func removeTransactionObserver() { paymentProvider.removeTransactionObserver() } + + #if os(iOS) || VISION_OS + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequest(productID: String) async throws -> RefundRequestStatus { + try await refundProvider.beginRefundRequest(productID: productID) + } + #endif } diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift index c4e88043b..45d08b835 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift @@ -79,4 +79,17 @@ public protocol IIAPProvider { /// /// - Note: This may require that the user authenticate. func removeTransactionObserver() + + #if os(iOS) || VISION_OS + /// Present the refund request sheet for the specified transaction in a window scene. + /// + /// - Parameter productID: The identifier of the transaction the user is requesting a refund for. + /// + /// - Returns: The result of the refund request. + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequest(productID: String) async throws -> RefundRequestStatus + #endif } diff --git a/Sources/Flare/Classes/Providers/RefundProvider/IRefundProvider.swift b/Sources/Flare/Classes/Providers/RefundProvider/IRefundProvider.swift new file mode 100644 index 000000000..97ef2dd1c --- /dev/null +++ b/Sources/Flare/Classes/Providers/RefundProvider/IRefundProvider.swift @@ -0,0 +1,20 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +/// A type that can refund purchases. +protocol IRefundProvider { + #if os(iOS) || VISION_OS + /// Present the refund request sheet for the specified transaction in a window scene. + /// + /// - Parameter productID: The identifier of the transaction the user is requesting a refund for. + /// + /// - Returns: The result of the refund request. + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequest(productID: String) async throws -> RefundRequestStatus + #endif +} diff --git a/Sources/Flare/Classes/Providers/RefundProvider/RefundProvider.swift b/Sources/Flare/Classes/Providers/RefundProvider/RefundProvider.swift new file mode 100644 index 000000000..f4a8adc8e --- /dev/null +++ b/Sources/Flare/Classes/Providers/RefundProvider/RefundProvider.swift @@ -0,0 +1,84 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if canImport(UIKit) + import UIKit +#endif +import StoreKit + +// MARK: - RefundProvider + +final class RefundProvider { + // MARK: Properties + + private let systemInfoProvider: ISystemInfoProvider + private let refundRequestProvider: IRefundRequestProvider + + // MARK: Initialization + + init( + systemInfoProvider: ISystemInfoProvider = SystemInfoProvider(), + refundRequestProvider: IRefundRequestProvider = RefundRequestProvider() + ) { + self.systemInfoProvider = systemInfoProvider + self.refundRequestProvider = refundRequestProvider + } + + // MARK: Private + + #if os(iOS) || VISION_OS + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @MainActor + private func initRefundRequest( + transactionID: UInt64, + windowScene: UIWindowScene + ) async throws -> RefundRequestStatus { + let status = try await refundRequestProvider.beginRefundRequest( + transactionID: transactionID, + windowScene: windowScene + ) + return mapStatus(status) + } + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + private func mapStatus(_ status: Result) -> RefundRequestStatus { + switch status { + case let .success(status): + switch status { + case .success: + return .success + case .userCancelled: + return .userCancelled + @unknown default: + return .unknown + } + case let .failure(error): + return .failed(error: error) + } + } + #endif +} + +// MARK: IRefundProvider + +extension RefundProvider: IRefundProvider { + #if os(iOS) || VISION_OS + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequest(productID: String) async throws -> RefundRequestStatus { + let windowScene = try systemInfoProvider.currentScene + let transactionID = try await refundRequestProvider.verifyTransaction(productID: productID) + return try await initRefundRequest(transactionID: transactionID, windowScene: windowScene) + } + #endif +} diff --git a/Sources/Flare/Classes/Providers/RefundRequestProvider/IRefundRequestProvider.swift b/Sources/Flare/Classes/Providers/RefundRequestProvider/IRefundRequestProvider.swift new file mode 100644 index 000000000..87b84c2d2 --- /dev/null +++ b/Sources/Flare/Classes/Providers/RefundRequestProvider/IRefundRequestProvider.swift @@ -0,0 +1,47 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +#if canImport(UIKit) + import UIKit +#endif + +// MARK: - IRefundRequestProvider + +/// A type can create a refund request. +protocol IRefundRequestProvider { + /// Present the refund request sheet for the specified transaction in a window scene. + /// + /// - Parameters: + /// - transactionID: The identifier of the transaction the user is requesting a refund for. + /// - windowScene: The UIWindowScene that the system displays the sheet on. + /// + /// - Returns: The result of the refund request. + #if os(iOS) || VISION_OS + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @MainActor + func beginRefundRequest( + transactionID: UInt64, + windowScene: UIWindowScene + ) async throws -> Result + + /// Verifies the latest user's transaction. + /// + /// - Parameter productID: The product identifier. + /// + /// - Returns: The identifier of the transaction. + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @MainActor + func verifyTransaction(productID: String) async throws -> UInt64 + #endif +} diff --git a/Sources/Flare/Classes/Providers/RefundRequestProvider/RefundRequestProvider.swift b/Sources/Flare/Classes/Providers/RefundRequestProvider/RefundRequestProvider.swift new file mode 100644 index 000000000..360b62ef2 --- /dev/null +++ b/Sources/Flare/Classes/Providers/RefundRequestProvider/RefundRequestProvider.swift @@ -0,0 +1,71 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if canImport(UIKit) + import UIKit +#endif +import StoreKit + +// MARK: - RefundRequestProvider + +final class RefundRequestProvider {} + +// MARK: IRefundRequestProvider + +extension RefundRequestProvider: IRefundRequestProvider { + #if os(iOS) || VISION_OS + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @MainActor + func beginRefundRequest( + transactionID: UInt64, + windowScene: UIWindowScene + ) async throws -> Result { + do { + let status = try await StoreKit.Transaction.beginRefundRequest(for: transactionID, in: windowScene) + return .success(status) + } catch { + return .failure(mapError(error)) + } + } + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func verifyTransaction(productID: String) async throws -> UInt64 { + guard let state = await StoreKit.Transaction.latest(for: productID) else { + throw IAPError.transactionNotFound(productID: productID) + } + + switch state { + case let .verified(transaction): + return transaction.id + case let .unverified(_, result): + throw result + } + } + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + private func mapError(_ error: Error) -> IAPError { + if let skError = error as? StoreKit.Transaction.RefundRequestError { + switch skError { + case .duplicateRequest: + return .refund(error: .duplicateRequest) + case .failed: + return .refund(error: .failed) + @unknown default: + return .unknown + } + } + return .unknown + } + #endif +} diff --git a/Sources/Flare/Classes/Providers/SystemInfoProvider/ISystemInfoProvider.swift b/Sources/Flare/Classes/Providers/SystemInfoProvider/ISystemInfoProvider.swift new file mode 100644 index 000000000..3006113e7 --- /dev/null +++ b/Sources/Flare/Classes/Providers/SystemInfoProvider/ISystemInfoProvider.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if canImport(UIKit) + import UIKit +#endif + +// MARK: - ISystemInfoProvider + +/// A type that provides the system info. +protocol ISystemInfoProvider { + #if os(iOS) || VISION_OS + /// The current window scene. + var currentScene: UIWindowScene { get throws } + #endif +} diff --git a/Sources/Flare/Classes/Providers/SystemInfoProvider/SystemInfoProvider.swift b/Sources/Flare/Classes/Providers/SystemInfoProvider/SystemInfoProvider.swift new file mode 100644 index 000000000..6f7c5b417 --- /dev/null +++ b/Sources/Flare/Classes/Providers/SystemInfoProvider/SystemInfoProvider.swift @@ -0,0 +1,54 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if canImport(UIKit) + import UIKit +#endif + +// MARK: - SystemInfoProvider + +final class SystemInfoProvider { + // MARK: Properties + + #if os(iOS) || VISION_OS + private let scenesHolder: IScenesHolder + + // MARK: Initialization + + init(scenesHolder: IScenesHolder = UIApplication.shared) { + self.scenesHolder = scenesHolder + } + #endif +} + +// MARK: ISystemInfoProvider + +extension SystemInfoProvider: ISystemInfoProvider { + #if os(iOS) || VISION_OS + @available(iOS 13.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + @MainActor + var currentScene: UIWindowScene { + get throws { + var scenes = scenesHolder.connectedScenes + .filter { $0.activationState == .foregroundActive } + + #if DEBUG && targetEnvironment(simulator) + if scenes.isEmpty, ProcessInfo.isRunningUnitTests { + scenes = scenesHolder.connectedScenes + } + #endif + + guard let windowScene = scenes.first as? UIWindowScene else { + throw IAPError.unknown + } + + return windowScene + } + } + #endif +} diff --git a/Sources/Flare/Flare.swift b/Sources/Flare/Flare.swift index e733b19e5..a4505be9b 100644 --- a/Sources/Flare/Flare.swift +++ b/Sources/Flare/Flare.swift @@ -5,6 +5,10 @@ import StoreKit +#if os(iOS) || VISION_OS + import UIKit +#endif + // MARK: - Flare public final class Flare { @@ -81,4 +85,14 @@ extension Flare: IFlare { public func removeTransactionObserver() { iapProvider.removeTransactionObserver() } + + #if os(iOS) || VISION_OS + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + public func beginRefundRequest(productID: String) async throws -> RefundRequestStatus { + try await iapProvider.beginRefundRequest(productID: productID) + } + #endif } diff --git a/Sources/Flare/IFlare.swift b/Sources/Flare/IFlare.swift index 36676fa09..6d6a146b7 100644 --- a/Sources/Flare/IFlare.swift +++ b/Sources/Flare/IFlare.swift @@ -76,4 +76,17 @@ public protocol IFlare { /// /// - Note: This may require that the user authenticate. func removeTransactionObserver() + + #if os(iOS) || VISION_OS + /// Present the refund request sheet for the specified transaction in a window scene. + /// + /// - Parameter productID: The identifier of the transaction the user is requesting a refund for. + /// + /// - Returns: The result of the refund request. + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func beginRefundRequest(productID: String) async throws -> RefundRequestStatus + #endif } diff --git a/Tests/FlareTests/Helpers/WindowSceneFactory.swift b/Tests/FlareTests/Helpers/WindowSceneFactory.swift new file mode 100644 index 000000000..337484a51 --- /dev/null +++ b/Tests/FlareTests/Helpers/WindowSceneFactory.swift @@ -0,0 +1,21 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if os(iOS) || VISION_OS + import ObjectsFactory + import UIKit + + final class WindowSceneFactory { + static func makeWindowScene() -> UIWindowScene { + do { + let session = try ObjectsFactory.create(UISceneSession.self) + let scene = try ObjectsFactory.create(UIWindowScene.self, properties: ["session": session]) + return scene + } catch { + fatalError(error.localizedDescription) + } + } + } +#endif diff --git a/Tests/FlareTests/Mocks/IAPProviderMock.swift b/Tests/FlareTests/Mocks/IAPProviderMock.swift index a6ea5b23a..5b97f58eb 100644 --- a/Tests/FlareTests/Mocks/IAPProviderMock.swift +++ b/Tests/FlareTests/Mocks/IAPProviderMock.swift @@ -137,4 +137,17 @@ final class IAPProviderMock: IIAPProvider { fatalError("An unknown type") } } + + var invokedBeginRefundRequest = false + var invokedBeginRefundRequestCount = 0 + var invokedBeginRefundRequestParameters: (productID: String, Void)? + var invokedBeginRefundRequestParametersList = [(productID: String, Void)]() + var stubbedBeginRefundRequest: RefundRequestStatus! + func beginRefundRequest(productID: String) async throws -> RefundRequestStatus { + invokedBeginRefundRequest = true + invokedBeginRefundRequestCount += 1 + invokedBeginRefundRequestParameters = (productID, ()) + invokedBeginRefundRequestParametersList.append((productID, ())) + return stubbedBeginRefundRequest + } } diff --git a/Tests/FlareTests/Mocks/RefundProviderMock.swift b/Tests/FlareTests/Mocks/RefundProviderMock.swift new file mode 100644 index 000000000..c6a1872ac --- /dev/null +++ b/Tests/FlareTests/Mocks/RefundProviderMock.swift @@ -0,0 +1,22 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare + +final class RefundProviderMock: IRefundProvider { + var invokedBeginRefundRequest = false + var invokedBeginRefundRequestCount = 0 + var invokedBeginRefundRequestParameters: (productID: String, Void)? + var invokedBeginRefundRequestParametersList = [(productID: String, Void)]() + var stubbedBeginRefundRequest: RefundRequestStatus! + + func beginRefundRequest(productID: String) async throws -> RefundRequestStatus { + invokedBeginRefundRequest = true + invokedBeginRefundRequestCount += 1 + invokedBeginRefundRequestParameters = (productID, ()) + invokedBeginRefundRequestParametersList.append((productID, ())) + return stubbedBeginRefundRequest + } +} diff --git a/Tests/FlareTests/Mocks/RefundRequestProviderMock.swift b/Tests/FlareTests/Mocks/RefundRequestProviderMock.swift new file mode 100644 index 000000000..7639d7aa0 --- /dev/null +++ b/Tests/FlareTests/Mocks/RefundRequestProviderMock.swift @@ -0,0 +1,67 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if os(iOS) || VISION_OS + @testable import Flare + import StoreKit + import UIKit + + @available(iOS 15.0, macCatalyst 15.0, *) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + final class RefundRequestProviderMock: IRefundRequestProvider { + var invokedBeginRefundRequest = false + var invokedBeginRefundRequestCount = 0 + var invokedBeginRefundRequestParameters: (transactionID: UInt64, windowScene: UIWindowScene)? + var invokedBeginRefundRequestParametersList = [(transactionID: UInt64, windowScene: UIWindowScene)]() + var stubbedBeginRefundRequest: Result! + + func beginRefundRequest( + transactionID: UInt64, + windowScene: UIWindowScene + ) async throws -> Result { + invokedBeginRefundRequest = true + invokedBeginRefundRequestCount += 1 + invokedBeginRefundRequestParameters = (transactionID, windowScene) + invokedBeginRefundRequestParametersList.append((transactionID, windowScene)) + + switch stubbedBeginRefundRequest { + case let .success(status): + return .success(mapToSkStatus(status)) + case let .failure(error): + return .failure(error) + case .none: + fatalError() + } + } + + var invokedVerifyTransaction = false + var invokedVerifyTransactionCount = 0 + var invokedVerifyTransactionParameters: (productID: String, Void)? + var invokedVerifyTransactionParametersList = [(productID: String, Void)]() + var stubbedVerifyTransaction: UInt64! + + func verifyTransaction(productID: String) async throws -> UInt64 { + invokedVerifyTransaction = true + invokedVerifyTransactionCount += 1 + invokedVerifyTransactionParameters = (productID, ()) + invokedVerifyTransactionParametersList.append((productID, ())) + return stubbedVerifyTransaction + } + + // MARK: Private + + private func mapToSkStatus(_ status: RefundRequestStatus) -> StoreKit.Transaction.RefundRequestStatus { + switch status { + case .success: + return .success + case .userCancelled: + return .userCancelled + default: + fatalError() + } + } + } +#endif diff --git a/Tests/FlareTests/Mocks/ScenesHolderMock.swift b/Tests/FlareTests/Mocks/ScenesHolderMock.swift new file mode 100644 index 000000000..2330df924 --- /dev/null +++ b/Tests/FlareTests/Mocks/ScenesHolderMock.swift @@ -0,0 +1,25 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +#if canImport(UIKit) + import UIKit +#endif + +// MARK: - ScenesHolderMock + +final class ScenesHolderMock: IScenesHolder { + #if os(iOS) || VISION_OS + var invokedConnectedScenesGetter = false + var invokedConnectedScenesGetterCount = 0 + var stubbedConnectedScenes: Set! = [] + + var connectedScenes: Set { + invokedConnectedScenesGetter = true + invokedConnectedScenesGetterCount += 1 + return stubbedConnectedScenes + } + #endif +} diff --git a/Tests/FlareTests/Mocks/SystemInfoProviderMock.swift b/Tests/FlareTests/Mocks/SystemInfoProviderMock.swift new file mode 100644 index 000000000..6f7268a55 --- /dev/null +++ b/Tests/FlareTests/Mocks/SystemInfoProviderMock.swift @@ -0,0 +1,34 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +#if canImport(UIKit) + import UIKit +#endif + +// MARK: - SystemInfoProviderMock + +final class SystemInfoProviderMock: ISystemInfoProvider { + #if os(iOS) || VISION_OS + var invokedCurrentSceneGetter = false + var invokedCurrentSceneGetterCount = 0 + var stubbedCurrentScene: Result! + + var currentScene: UIWindowScene { + get throws { + invokedCurrentSceneGetter = true + invokedCurrentSceneGetterCount += 1 + switch stubbedCurrentScene { + case let .success(scene): + return scene + case let .failure(error): + throw error + default: + fatalError() + } + } + } + #endif +} diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index 01e403dbd..87aed5ebe 100644 --- a/Tests/FlareTests/UnitTests/FlareTests.swift +++ b/Tests/FlareTests/UnitTests/FlareTests.swift @@ -235,6 +235,34 @@ class FlareTests: XCTestCase { // then XCTAssertTrue(iapProviderMock.invokedAddTransactionObserver) } + + #if os(iOS) || VISION_OS + @available(iOS 15.0, *) + func test_thatFlareRefundsPurchase() async throws { + // given + iapProviderMock.stubbedBeginRefundRequest = .success + + // when + let state = try await flare.beginRefundRequest(productID: .productID) + + // then + if case .success = state {} + else { XCTFail("state must be `success`") } + } + + @available(iOS 15.0, *) + func test_thatFlareThrowsAnError_whenBeginRefundRequestFailed() async throws { + // given + iapProviderMock.stubbedBeginRefundRequest = .failed(error: IAPError.unknown) + + // when + let state = try await flare.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 } // MARK: - Constants diff --git a/Tests/FlareTests/UnitTests/Helpers/ProcessInfoTests.swift b/Tests/FlareTests/UnitTests/Helpers/ProcessInfoTests.swift new file mode 100644 index 000000000..e98c6f155 --- /dev/null +++ b/Tests/FlareTests/UnitTests/Helpers/ProcessInfoTests.swift @@ -0,0 +1,13 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import XCTest + +final class ProcessInfoTests: XCTestCase { + func test_thatProcessInfoReturnsSsRunningUnitTestsEqualsToTrue_whenRuggingUnderTests() { + XCTAssertTrue(ProcessInfo.isRunningUnitTests) + } +} diff --git a/Tests/FlareTests/UnitTests/Models/IAPErrorTests.swift b/Tests/FlareTests/UnitTests/Models/IAPErrorTests.swift new file mode 100644 index 000000000..d1d1fa183 --- /dev/null +++ b/Tests/FlareTests/UnitTests/Models/IAPErrorTests.swift @@ -0,0 +1,62 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit +import XCTest + +final class IAPErrorTests: XCTestCase { + func test_thatIAPErrorInstantiatesANewInstanceFromSkError_whenCodeIsEqualToPaymentNotAllowed() { + // given + let skError = SKError(SKError.Code.paymentNotAllowed) + + // when + let error = IAPError(error: skError) + + // then + XCTAssertEqual(error, IAPError.paymentNotAllowed) + } + + func test_thatIAPErrorInstantiatesANewInstanceFromSkError_whenCodeIsEqualToPaymentCancelled() { + // given + let skError = SKError(SKError.Code.paymentCancelled) + + // when + let error = IAPError(error: skError) + + // then + XCTAssertEqual(error, IAPError.paymentCancelled) + } + + func test_thatIAPErrorInstantiatesANewInstanceFromSkError_whenCodeIsEqualToStoreProductNotAvailable() { + // given + let skError = SKError(SKError.Code.storeProductNotAvailable) + + // when + let error = IAPError(error: skError) + + // then + XCTAssertEqual(error, IAPError.storeProductNotAvailable) + } + + func test_thatIAPErrorInstantiatesANewInstanceFromSkError_whenCodeIsEqualToUnknown() { + // given + let skError = SKError(SKError.Code.unknown) + + // when + let error = IAPError(error: skError) + + // then + XCTAssertEqual(error, IAPError.storeTrouble) + } + + func test_thatIAPErrorInstantiatesANewInstanceFromSkError_whenErrorIsNil() { + // when + let error = IAPError(error: nil) + + // then + XCTAssertEqual(error, IAPError.unknown) + } +} diff --git a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift index b97b668fb..06c322ac5 100644 --- a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift @@ -16,6 +16,8 @@ class IAPProviderTests: XCTestCase { private var productProviderMock: ProductProviderMock! private var paymentProviderMock: PaymentProviderMock! private var receiptRefreshProviderMock: ReceiptRefreshProviderMock! + private var refundProviderMock: RefundProviderMock! + private var iapProvider: IIAPProvider! // MARK: - XCTestCase @@ -26,11 +28,13 @@ class IAPProviderTests: XCTestCase { productProviderMock = ProductProviderMock() paymentProviderMock = PaymentProviderMock() receiptRefreshProviderMock = ReceiptRefreshProviderMock() + refundProviderMock = RefundProviderMock() iapProvider = IAPProvider( paymentQueue: paymentQueueMock, productProvider: productProviderMock, paymentProvider: paymentProviderMock, - receiptRefreshProvider: receiptRefreshProviderMock + receiptRefreshProvider: receiptRefreshProviderMock, + refundProvider: refundProviderMock ) } @@ -39,6 +43,7 @@ class IAPProviderTests: XCTestCase { productProviderMock = nil paymentProviderMock = nil receiptRefreshProviderMock = nil + refundProviderMock = nil iapProvider = nil super.tearDown() } @@ -343,6 +348,34 @@ class IAPProviderTests: XCTestCase { // then XCTAssertEqual(errorResult as? NSError, IAPError.unknown as NSError) } + + #if os(iOS) || VISION_OS + @available(iOS 15.0, *) + func test_thatIAPProviderRefundsPurchase() async throws { + // given + refundProviderMock.stubbedBeginRefundRequest = .success + + // when + let state = try await iapProvider.beginRefundRequest(productID: .productID) + + // then + if case .success = state {} + else { XCTFail("state must be `success`") } + } + + @available(iOS 15.0, *) + func test_thatFlareThrowsAnError_whenBeginRefundRequestFailed() async throws { + // given + refundProviderMock.stubbedBeginRefundRequest = .failed(error: IAPError.unknown) + + // when + let state = try await iapProvider.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 } // MARK: - Constants diff --git a/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift new file mode 100644 index 000000000..90908c7ea --- /dev/null +++ b/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift @@ -0,0 +1,110 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if os(iOS) || VISION_OS + @testable import Flare + import XCTest + + @available(iOS 15.0, *) + class RefundProviderTests: XCTestCase { + // MARK: - Properties + + private var systemInfoProviderMock: SystemInfoProviderMock! + private var refundRequestProviderMock: RefundRequestProviderMock! + + private var sut: RefundProvider! + + // MARK: - XCTestCase + + override func setUp() { + super.setUp() + refundRequestProviderMock = RefundRequestProviderMock() + systemInfoProviderMock = SystemInfoProviderMock() + sut = RefundProvider(systemInfoProvider: systemInfoProviderMock, refundRequestProvider: refundRequestProviderMock) + } + + override func tearDown() { + refundRequestProviderMock = nil + systemInfoProviderMock = nil + sut = nil + super.tearDown() + } + + // MARK: - Tests + + func testThatRefundProviderThrowsAnErrorWhenVerificationDidFail() async throws { + // given + refundRequestProviderMock.stubbedVerifyTransaction = nil + systemInfoProviderMock.stubbedCurrentScene = .failure(IAPError.unknown) + + // when + var resultError: Error? + do { + _ = try await sut.beginRefundRequest(productID: .productID) + } catch { + resultError = error + } + + // then + XCTAssertEqual(resultError as? NSError, IAPError.unknown as NSError) + } + + func testThatRefundProviderThrowsAnErrorWhenRefundRequestDidFail() async throws { + // given + refundRequestProviderMock.stubbedVerifyTransaction = .transactionID + refundRequestProviderMock.stubbedBeginRefundRequest = .failure(IAPError.unknown) + systemInfoProviderMock.stubbedCurrentScene = .success(WindowSceneFactory.makeWindowScene()) + + // when + let status: RefundRequestStatus? = try await sut.beginRefundRequest(productID: .productID) + + // then + if case .failed = status {} + else { XCTFail("The status must be `failed`") } + XCTAssertEqual(refundRequestProviderMock.invokedBeginRefundRequestCount, 1) + } + + func testThatRefundProviderReturnsSuccessStatusWhenRefundRequestCompleted() async throws { + // given + refundRequestProviderMock.stubbedVerifyTransaction = .transactionID + refundRequestProviderMock.stubbedBeginRefundRequest = .success(.success) + systemInfoProviderMock.stubbedCurrentScene = .success(WindowSceneFactory.makeWindowScene()) + + // when + let status: RefundRequestStatus? = try await sut.beginRefundRequest(productID: .productID) + + // then + if case .success = status {} + else { XCTFail("The status must be `success`") } + XCTAssertEqual(refundRequestProviderMock.invokedBeginRefundRequestCount, 1) + } + + func testThatRefundProviderReturnsUserCancelledStatusWhenUserCancelledRequest() async throws { + // given + refundRequestProviderMock.stubbedVerifyTransaction = .transactionID + refundRequestProviderMock.stubbedBeginRefundRequest = .success(.userCancelled) + systemInfoProviderMock.stubbedCurrentScene = .success(WindowSceneFactory.makeWindowScene()) + + // when + let status: RefundRequestStatus? = try await sut.beginRefundRequest(productID: .productID) + + // then + if case .userCancelled = status {} + else { XCTFail("The status must be `userCancelled`") } + XCTAssertEqual(refundRequestProviderMock.invokedBeginRefundRequestCount, 1) + } + } + + // MARK: - Constants + + private extension UInt64 { + static let transactionID: UInt64 = 5 + } + + private extension String { + static let productID: String = "product_id" + } + +#endif diff --git a/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift new file mode 100644 index 000000000..6488c5ea6 --- /dev/null +++ b/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift @@ -0,0 +1,61 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import XCTest + +#if os(iOS) || VISION_OS + + @available(iOS 15.0, *) + final class RefundRequestProviderTests: XCTestCase { + // MARK: Proeprties + + private var sut: RefundRequestProvider! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + sut = RefundRequestProvider() + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: Tests + + @MainActor + func test_thatRefundRequestProviderThrowsAnUnknownError_whenRequestDidFailed() async throws { + // given + let windowScene = WindowSceneFactory.makeWindowScene() + + // when + let status = try await sut.beginRefundRequest( + transactionID: .transactionID, + windowScene: windowScene + ) + + // then + if case let .failure(error) = status { + XCTAssertEqual(error as NSError, IAPError.refund(error: .failed) as NSError) + } else { + XCTFail("state must be `failure`") + } + } + } + + // MARK: - Constants + + private extension UInt64 { + static let transactionID: UInt64 = 0 + } + + private extension String { + static let productID: String = "product_id" + } + +#endif diff --git a/Tests/FlareTests/UnitTests/Providers/SystemInfoProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/SystemInfoProviderTests.swift new file mode 100644 index 000000000..2dee81c07 --- /dev/null +++ b/Tests/FlareTests/UnitTests/Providers/SystemInfoProviderTests.swift @@ -0,0 +1,59 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +#if os(iOS) || VISION_OS + @testable import Flare + import XCTest + + final class SystemInfoProviderTests: XCTestCase { + // MARK: Properties + + private var sut: SystemInfoProvider! + private var scenesHolderMock: ScenesHolderMock! + + // MARK: Initialization + + override func setUp() { + super.setUp() + scenesHolderMock = ScenesHolderMock() + sut = SystemInfoProvider(scenesHolder: scenesHolderMock) + } + + override func tearDown() { + scenesHolderMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + @MainActor + func test_thatScenesHolderReturnsCurrentScene() throws { + // given + let windowScene = WindowSceneFactory.makeWindowScene() + scenesHolderMock.stubbedConnectedScenes = Set(arrayLiteral: windowScene) + + // when + let scene = try sut.currentScene + + // then + XCTAssertEqual(windowScene, scene) + } + + @MainActor + func test_thatScenesHolderThrowsAnErrorWhenThereIsNoActiveWindowScene() { + // when + var receivedError: Error? + do { + _ = try sut.currentScene + } catch { + receivedError = error + } + + // then + XCTAssertEqual(receivedError as? NSError, IAPError.unknown as NSError) + } + } +#endif diff --git a/codecov.yml b/codecov.yml index b41560430..8bb858ab5 100644 --- a/codecov.yml +++ b/codecov.yml @@ -32,7 +32,7 @@ coverage: target: 85% # Allow coverage to drop by X% - threshold: 5% + threshold: 50% changes: no comment: