From d2b764aae392b008b2eabe3036264139fdffd16a Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Thu, 8 Aug 2024 14:14:55 +0400 Subject: [PATCH] Implement Restoring Transactions (#57) * Implement the `restore` method for StoreKit * Update the logic to refresh the receipt The method includes a new parameter, `updateTransactions`, which forces the update of transactions first. By default, this parameter is set to `false`. * Update `CHANGELOG.md` * Update `restore-purchase.md` --- CHANGELOG.md | 4 ++ Sources/Flare/Classes/Flare.swift | 15 +++- Sources/Flare/Classes/IFlare.swift | 69 ++++++++++++++++--- .../Providers/IAPProvider/IAPProvider.swift | 54 +++++++++++---- .../Providers/IAPProvider/IIAPProvider.swift | 51 +++++++++++++- .../PurchaseProvider/IPurchaseProvider.swift | 23 ++++++- .../PurchaseProvider/PurchaseProvider.swift | 21 +++++- .../Flare.docc/Articles/restore-purchase.md | 2 + Sources/FlareUIMock/Mocks/FlareMock.swift | 3 +- .../TestHelpers/Mocks/IAPProviderMock.swift | 16 ++++- .../Mocks/PurchaseProviderMock.swift | 3 +- 11 files changed, 229 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c1935d3..9dc9ec272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## Added +- Implement restoring transactions for StoreKit 1 + - Added in Pull Request [#57](https://github.com/space-code/flare/pull/57). + ## Updated - Update `codecov` version - Updated in Pull Request [#59](https://github.com/space-code/flare/pull/59) diff --git a/Sources/Flare/Classes/Flare.swift b/Sources/Flare/Classes/Flare.swift index d2e5f2842..4d508e60b 100644 --- a/Sources/Flare/Classes/Flare.swift +++ b/Sources/Flare/Classes/Flare.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2024 Space Code. All rights reserved. +// Copyright © 2023 Space Code. All rights reserved. // import struct Log.LogLevel @@ -153,11 +153,22 @@ extension Flare: IFlare { try await iapProvider.checkEligibility(productIDs: productIDs) } - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) public func restore() async throws { try await iapProvider.restore() } + public func restore(_ completion: @escaping (Result) -> Void) { + iapProvider.restore(completion) + } + + public func receipt(updateTransactions: Bool) async throws -> String { + try await iapProvider.refreshReceipt(updateTransactions: updateTransactions) + } + + public func receipt(updateTransactions: Bool, completion: @escaping (Result) -> Void) { + iapProvider.refreshReceipt(updateTransactions: updateTransactions, completion: completion) + } + #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 c108dd85c..0cc638c80 100644 --- a/Sources/Flare/Classes/IFlare.swift +++ b/Sources/Flare/Classes/IFlare.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2024 Space Code. All rights reserved. +// Copyright © 2023 Space Code. All rights reserved. // import Foundation @@ -105,17 +105,31 @@ public protocol IFlare { promotionalOffer: PromotionalOffer? ) async throws -> StoreTransaction - /// Refreshes the receipt, representing the user's transactions with your app. + /// Refreshes the receipt and optionally updates transactions. /// - /// - Parameter completion: The closure to be executed when the refresh operation ends. - func receipt(completion: @escaping Closure>) + /// - Parameters: + /// - updateTransactions: A boolean indicating whether to update transactions. + /// - If `true`, the method will refresh completed transactions. + /// - If `false`, only the receipt will be refreshed. + /// - completion: A closure that gets called with the result of the refresh operation. + /// - On success, it returns a `Result` containing the updated receipt information as a `String`. + /// - On failure, it returns a `Result` with an `IAPError` describing the issue. + /// + /// - Note: Use this method to handle asynchronous receipt refreshing and transaction updates with completion handler feedback. + func receipt(updateTransactions: Bool, completion: @escaping (Result) -> Void) - /// Refreshes the receipt, representing the user's transactions with your app. + /// Refreshes the receipt and optionally updates transactions. /// - /// `IAPError(error:)` if the request did fail with error. + /// - Parameter updateTransactions: A boolean indicating whether to update transactions. + /// - If `true`, the method will refresh completed transactions. + /// - If `false`, only the receipt will be refreshed. /// - /// - Returns: A receipt. - func receipt() async throws -> String + /// - Returns: A `String` containing the updated receipt information. + /// + /// - Throws: An `IAPError` if the refresh process encounters an issue. + /// + /// - Note: Use this method for an asynchronous refresh operation with error handling and receipt data retrieval. + func receipt(updateTransactions: Bool) async throws -> String /// Removes a finished (i.e. failed or completed) transaction from the queue. /// Attempting to finish a purchasing transaction will throw an exception. @@ -151,9 +165,30 @@ public protocol IFlare { @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func checkEligibility(productIDs: Set) async throws -> [String: SubscriptionEligibility] - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + /// Restores completed transactions. + /// + /// This method initiates the process of restoring any previously completed transactions. + /// It is an asynchronous function that might throw an error if the restoration fails. + /// + /// - Throws: An error if the restoration process encounters an issue. + /// + /// - Note: This method should be called when you need to restore purchases made by the user on a different device or after + /// reinstallation. func restore() async throws + /// Restores completed transactions. + /// + /// This method initiates the process of restoring any previously completed transactions. + /// It uses a completion handler to provide the result of the restoration process. + /// + /// - Parameter completion: A closure that gets called with a `Result` indicating success or failure of the restoration. + /// - On success, it returns `Result.success(())`. + /// - On failure, it returns `Result.failure(Error)` with an error describing the issue. + /// + /// - Note: Use this method when you need to handle the restoration process asynchronously and provide feedback through the completion + /// handler. + func restore(_ completion: @escaping (Result) -> Void) + #if os(iOS) || VISION_OS /// Present the refund request sheet for the specified transaction in a window scene. /// @@ -259,4 +294,20 @@ public extension IFlare { ) async throws -> StoreTransaction { try await purchase(product: product, options: options, promotionalOffer: nil) } + + /// Refreshes the receipt, representing the user's transactions with your app. + /// + /// - Parameter completion: The closure to be executed when the refresh operation ends. + func receipt(completion: @escaping Closure>) { + receipt(updateTransactions: false, completion: completion) + } + + /// Refreshes the receipt, representing the user's transactions with your app. + /// + /// `IAPError(error:)` if the request did fail with error. + /// + /// - Returns: A receipt. + func receipt() async throws -> String { + try await receipt(updateTransactions: false) + } } diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift index fd787f988..15a76942b 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2024 Space Code. All rights reserved. +// Copyright © 2023 Space Code. All rights reserved. // import StoreKit @@ -143,19 +143,46 @@ final class IAPProvider: IIAPProvider { } } - func refreshReceipt(completion: @escaping Closure>) { - receiptRefreshProvider.refresh(requestID: UUID().uuidString) { [weak self] result in - switch result { - case .success: - if let receipt = self?.receiptRefreshProvider.receipt { - completion(.success(receipt)) - } else { - completion(.failure(.receiptNotFound)) + func refreshReceipt(updateTransactions: Bool) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + refreshReceipt(updateTransactions: updateTransactions) { result in + continuation.resume(with: result) + } + } + } + + func refreshReceipt(updateTransactions: Bool, completion: @escaping (Result) -> Void) { + let refresh = { [weak self] in + self?.receiptRefreshProvider.refresh(requestID: UUID().uuidString) { [weak self] result in + switch result { + case .success: + if let receipt = self?.receiptRefreshProvider.receipt { + completion(.success(receipt)) + } else { + completion(.failure(.receiptNotFound)) + } + case let .failure(error): + completion(.failure(error)) } - case let .failure(error): - completion(.failure(error)) } } + + if updateTransactions { + restore { result in + switch result { + case .success: + refresh() + case let .failure(error): + completion(.failure(IAPError.with(error: error))) + } + } + } else { + refresh() + } + } + + func refreshReceipt(completion: @escaping Closure>) { + refreshReceipt(updateTransactions: false, completion: completion) } func refreshReceipt() async throws -> String { @@ -192,11 +219,14 @@ final class IAPProvider: IIAPProvider { return try await eligibilityProvider.checkEligibility(products: products) } - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func restore() async throws { try await purchaseProvider.restore() } + func restore(_ completion: @escaping (Result) -> Void) { + purchaseProvider.restore(completion) + } + #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 318a1435f..c2b18e33f 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2024 Space Code. All rights reserved. +// Copyright © 2023 Space Code. All rights reserved. // import StoreKit @@ -103,6 +103,32 @@ public protocol IIAPProvider { promotionalOffer: PromotionalOffer? ) async throws -> StoreTransaction + /// Refreshes the receipt and optionally updates transactions. + /// + /// - Parameters: + /// - updateTransactions: A boolean indicating whether to update transactions. + /// - If `true`, the method will refresh completed transactions. + /// - If `false`, only the receipt will be refreshed. + /// - completion: A closure that gets called with the result of the refresh operation. + /// - On success, it returns a `Result` containing the updated receipt information as a `String`. + /// - On failure, it returns a `Result` with an `IAPError` describing the issue. + /// + /// - Note: Use this method to handle asynchronous receipt refreshing and transaction updates with completion handler feedback. + func refreshReceipt(updateTransactions: Bool, completion: @escaping (Result) -> Void) + + /// Refreshes the receipt and optionally updates transactions. + /// + /// - Parameter updateTransactions: A boolean indicating whether to update transactions. + /// - If `true`, the method will refresh completed transactions. + /// - If `false`, only the receipt will be refreshed. + /// + /// - Returns: A `String` containing the updated receipt information. + /// + /// - Throws: An `IAPError` if the refresh process encounters an issue. + /// + /// - Note: Use this method for an asynchronous refresh operation with error handling and receipt data retrieval. + func refreshReceipt(updateTransactions: Bool) async throws -> String + /// Refreshes the receipt, representing the user's transactions with your app. /// /// - Parameter completion: The closure to be executed when the refresh operation ends. @@ -150,9 +176,30 @@ public protocol IIAPProvider { @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func checkEligibility(productIDs: Set) async throws -> [String: SubscriptionEligibility] - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + /// Restores completed transactions. + /// + /// This method initiates the process of restoring any previously completed transactions. + /// It is an asynchronous function that might throw an error if the restoration fails. + /// + /// - Throws: An error if the restoration process encounters an issue. + /// + /// - Note: This method should be called when you need to restore purchases made by the user on a different device or after + /// reinstallation. func restore() async throws + /// Restores completed transactions. + /// + /// This method initiates the process of restoring any previously completed transactions. + /// It uses a completion handler to provide the result of the restoration process. + /// + /// - Parameter completion: A closure that gets called with a `Result` indicating success or failure of the restoration. + /// - On success, it returns `Result.success(())`. + /// - On failure, it returns `Result.failure(Error)` with an error describing the issue. + /// + /// - Note: Use this method when you need to handle the restoration process asynchronously and provide feedback through the completion + /// handler. + func restore(_ completion: @escaping (Result) -> Void) + #if os(iOS) || VISION_OS /// Present the refund request sheet for the specified transaction in a window scene. /// diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift index cd290ad09..f5951a3a6 100644 --- a/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift @@ -58,8 +58,29 @@ protocol IPurchaseProvider { completion: @escaping PurchaseCompletionHandler ) - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + /// Restores completed transactions. + /// + /// This method initiates the process of restoring any previously completed transactions. + /// It is an asynchronous function that might throw an error if the restoration fails. + /// + /// - Throws: An error if the restoration process encounters an issue. + /// + /// - Note: This method should be called when you need to restore purchases made by the user on a different device or after + /// reinstallation. func restore() async throws + + /// Restores completed transactions. + /// + /// This method initiates the process of restoring any previously completed transactions. + /// It uses a completion handler to provide the result of the restoration process. + /// + /// - Parameter completion: A closure that gets called with a `Result` indicating success or failure of the restoration. + /// - On success, it returns `Result.success(())`. + /// - On failure, it returns `Result.failure(Error)` with an error describing the issue. + /// + /// - Note: Use this method when you need to handle the restoration process asynchronously and provide feedback through the completion + /// handler. + func restore(_ completion: @escaping (Result) -> Void) } extension IPurchaseProvider { diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift index 479ffcd84..2290c6871 100644 --- a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift @@ -230,9 +230,26 @@ extension PurchaseProvider: IPurchaseProvider { paymentProvider.removeTransactionObserver() } - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func restore() async throws { - try await AppStore.sync() + if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + try await AppStore.sync() + } else { + try await withCheckedThrowingContinuation { continuation in + restore { result in + continuation.resume(with: result) + } + } + } + } + + func restore(_ completion: @escaping (Result) -> Void) { + paymentProvider.restoreCompletedTransactions { _, error in + if let error = error { + completion(.failure(error)) + } else { + completion(.success(())) + } + } } } diff --git a/Sources/Flare/Flare.docc/Articles/restore-purchase.md b/Sources/Flare/Flare.docc/Articles/restore-purchase.md index 1164954b4..b4644da4d 100644 --- a/Sources/Flare/Flare.docc/Articles/restore-purchase.md +++ b/Sources/Flare/Flare.docc/Articles/restore-purchase.md @@ -34,3 +34,5 @@ There is an ``IFlare/receipt()`` method for obtaining a receipt using async/awai ```swift let receipt = try await Flare.shared.receipt() ``` + +The ``IFlare/receipt(updateTransactions:completion:)`` method has a parameter, `updateTransactions`, which controls whether transactions are updated first. diff --git a/Sources/FlareUIMock/Mocks/FlareMock.swift b/Sources/FlareUIMock/Mocks/FlareMock.swift index 364f3cfa6..fa3d1c50e 100644 --- a/Sources/FlareUIMock/Mocks/FlareMock.swift +++ b/Sources/FlareUIMock/Mocks/FlareMock.swift @@ -250,12 +250,13 @@ public final class FlareMock: IFlare { public var invokedRestore = false public var invokedRestoreCount = 0 - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) public func restore() async throws { invokedRestore = true invokedRestoreCount += 1 } + public func restore(_: @escaping (Result) -> Void) {} + #if os(iOS) || VISION_OS public var invokedBeginRefundRequest = false public var invokedBeginRefundRequestCount = 0 diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift index 296b91b41..610bd92a8 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift @@ -1,6 +1,6 @@ // // Flare -// Copyright © 2024 Space Code. All rights reserved. +// Copyright © 2023 Space Code. All rights reserved. // @testable import Flare @@ -297,6 +297,18 @@ final class IAPProviderMock: IIAPProvider { invokedPresentOfferCodeRedeemSheetCount += 1 } - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func restore() async throws {} + + func restore(_: @escaping (Result) -> Void) {} + + var invokedRefreshReceiptUpdateTransaction = false + var invokedRefreshReceiptUpdateTransactionCount = 0 + var stubbedInvokedRefreshReceiptUpdateTransaction: String = "" + func refreshReceipt(updateTransactions _: Bool) async throws -> String { + invokedRefreshReceiptUpdateTransaction = true + invokedRefreshReceiptUpdateTransactionCount += 1 + return stubbedInvokedRefreshReceiptUpdateTransaction + } + + func refreshReceipt(updateTransactions _: Bool, completion _: @escaping (Result) -> Void) {} } diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift index f58fd3121..ec176852a 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift @@ -81,6 +81,7 @@ final class PurchaseProviderMock: IPurchaseProvider { } } - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func restore() async throws {} + + func restore(_: @escaping (Result) -> Void) {} }