From 316e2e8555361d9a8567668d3d7a07047aa12c5f Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Mon, 23 Oct 2023 09:25:37 -0700 Subject: [PATCH 1/2] `Paywalls`: new `.onPurchaseCompleted` overload with `StoreTransaction` The current API only provided a `CustomerInfo`. Note that this does not include `userCanceled`. We can expose separately with another `.onPurchaseCancelled` modifier in the future. --- RevenueCatUI/PaywallView.swift | 4 +- .../Purchasing/PurchaseHandler+TestData.swift | 1 + RevenueCatUI/Purchasing/PurchaseHandler.swift | 25 ++++++-- .../UIKit/PaywallViewController.swift | 11 +++- .../View+PurchaseRestoreCompleted.swift | 49 ++++++++++++++-- .../SwiftAPITester/PaywallViewAPI.swift | 58 +++++++++++-------- .../PaywallViewControllerAPI.swift | 4 ++ .../PurchaseCompletedHandlerTests.swift | 23 ++++++++ .../Purchasing/PurchaseHandlerTests.swift | 8 +-- 9 files changed, 142 insertions(+), 41 deletions(-) diff --git a/RevenueCatUI/PaywallView.swift b/RevenueCatUI/PaywallView.swift index b4c450f858..09856d185d 100644 --- a/RevenueCatUI/PaywallView.swift +++ b/RevenueCatUI/PaywallView.swift @@ -274,8 +274,8 @@ struct LoadedOfferingPaywallView: View { locale: self.locale) .environmentObject(self.introEligibility) .environmentObject(self.purchaseHandler) - .preference(key: PurchasedCustomerInfoPreferenceKey.self, - value: self.purchaseHandler.purchasedCustomerInfo) + .preference(key: PurchasedResultPreferenceKey.self, + value: .init(data: self.purchaseHandler.purchaseResult)) .preference(key: RestoredCustomerInfoPreferenceKey.self, value: self.purchaseHandler.restoredCustomerInfo) .disabled(self.purchaseHandler.actionInProgress) diff --git a/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift b/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift index b88004dd80..0ce9cd3c7b 100644 --- a/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift +++ b/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift @@ -23,6 +23,7 @@ extension PurchaseHandler { return self.init( purchases: MockPurchases { _ in return ( + // No current way to create a mock transaction with RevenueCat's public methods. transaction: nil, customerInfo: customerInfo, userCancelled: false diff --git a/RevenueCatUI/Purchasing/PurchaseHandler.swift b/RevenueCatUI/Purchasing/PurchaseHandler.swift index 22261d526e..d5cd2b7968 100644 --- a/RevenueCatUI/Purchasing/PurchaseHandler.swift +++ b/RevenueCatUI/Purchasing/PurchaseHandler.swift @@ -33,7 +33,7 @@ final class PurchaseHandler: ObservableObject { /// When `purchased` becomes `true`, this will include the `CustomerInfo` associated to it. @Published - fileprivate(set) var purchasedCustomerInfo: CustomerInfo? + fileprivate(set) var purchaseResult: PurchaseResultData? /// Whether a restore was successfully completed. @Published @@ -84,7 +84,7 @@ extension PurchaseHandler { } else { withAnimation(Constants.defaultAnimation) { self.purchased = true - self.purchasedCustomerInfo = result.customerInfo + self.purchaseResult = result } } @@ -189,11 +189,26 @@ private final class NotConfiguredPurchases: PaywallPurchasesType { // MARK: - Preference Keys @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) -struct PurchasedCustomerInfoPreferenceKey: PreferenceKey { +struct PurchasedResultPreferenceKey: PreferenceKey { - static var defaultValue: CustomerInfo? + struct PurchaseResult: Equatable { + var transaction: StoreTransaction? + var customerInfo: CustomerInfo - static func reduce(value: inout CustomerInfo?, nextValue: () -> CustomerInfo?) { + init(data: PurchaseResultData) { + self.transaction = data.transaction + self.customerInfo = data.customerInfo + } + + init?(data: PurchaseResultData?) { + guard let data else { return nil } + self.init(data: data) + } + } + + static var defaultValue: PurchaseResult? + + static func reduce(value: inout PurchaseResult?, nextValue: () -> PurchaseResult?) { value = nextValue() } diff --git a/RevenueCatUI/UIKit/PaywallViewController.swift b/RevenueCatUI/UIKit/PaywallViewController.swift index f3d970c881..e36a1318a5 100644 --- a/RevenueCatUI/UIKit/PaywallViewController.swift +++ b/RevenueCatUI/UIKit/PaywallViewController.swift @@ -45,9 +45,12 @@ public final class PaywallViewController: UIViewController { let paywallView = self.offering.map { PaywallView(offering: $0) } ?? PaywallView() let view = paywallView - .onPurchaseCompleted { [weak self] customerInfo in + .onPurchaseCompleted { [weak self] transaction, customerInfo in guard let self = self else { return } self.delegate?.paywallViewController?(self, didFinishPurchasingWith: customerInfo) + self.delegate?.paywallViewController?(self, + didFinishPurchasingWith: customerInfo, + transaction: transaction) } .onRestoreCompleted { [weak self] customerInfo in guard let self = self else { return } @@ -83,6 +86,12 @@ public protocol PaywallViewControllerDelegate: AnyObject { optional func paywallViewController(_ controller: PaywallViewController, didFinishPurchasingWith customerInfo: CustomerInfo) + /// Notifies that a purchase has completed in a ``PaywallViewController``. + @objc(paywallViewController:didFinishPurchasingWithCustomerInfo:transaction:) + optional func paywallViewController(_ controller: PaywallViewController, + didFinishPurchasingWith customerInfo: CustomerInfo, + transaction: StoreTransaction?) + /// Notifies that the restore operation has completed in a ``PaywallViewController``. /// /// - Warning: Receiving a ``CustomerInfo``does not imply that the user has any entitlements, diff --git a/RevenueCatUI/View+PurchaseRestoreCompleted.swift b/RevenueCatUI/View+PurchaseRestoreCompleted.swift index ff13d240ff..a3b32b23b6 100644 --- a/RevenueCatUI/View+PurchaseRestoreCompleted.swift +++ b/RevenueCatUI/View+PurchaseRestoreCompleted.swift @@ -17,6 +17,10 @@ import SwiftUI /// A closure used for notifying of purchase or restore completion. public typealias PurchaseOrRestoreCompletedHandler = @MainActor @Sendable (CustomerInfo) -> Void +/// A closure used for notifying of purchase completion. +public typealias PurchaseCompletedHandler = @MainActor @Sendable (_ transaction: StoreTransaction?, + _ customerInfo: CustomerInfo) -> Void + @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) @available(macOS, unavailable, message: "RevenueCatUI does not support macOS yet") extension View { @@ -34,7 +38,7 @@ extension View { /// PaywallView() /// .onPurchaseCompleted { customerInfo in /// print("Purchase completed: \(customerInfo.entitlements)") - /// self.didPurchase = false + /// self.displayPaywall = false /// } /// } /// } @@ -48,6 +52,33 @@ extension View { return self.modifier(OnPurchaseCompletedModifier(handler: handler)) } + /// Invokes the given closure when a purchase is completed. + /// The closure includes the `CustomerInfo` with unlocked entitlements. + /// Example: + /// ```swift + /// @State + /// private var displayPaywall: Bool = true + /// + /// var body: some View { + /// ContentView() + /// .sheet(isPresented: self.$displayPaywall) { + /// PaywallView() + /// .onPurchaseCompleted { transaction, customerInfo in + /// print("Purchase completed: \(customerInfo.entitlements)") + /// self.displayPaywall = false + /// } + /// } + /// } + /// ``` + /// + /// ### Related Articles + /// [Documentation](https://rev.cat/paywalls) + public func onPurchaseCompleted( + _ handler: @escaping PurchaseCompletedHandler + ) -> some View { + return self.modifier(OnPurchaseCompletedModifier(handler: handler)) + } + /// Invokes the given closure when restore purchases is completed. /// The closure includes the `CustomerInfo` after the process is completed. /// Example: @@ -85,13 +116,21 @@ extension View { @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) private struct OnPurchaseCompletedModifier: ViewModifier { - let handler: PurchaseOrRestoreCompletedHandler + let handler: PurchaseCompletedHandler + + init(handler: @escaping PurchaseOrRestoreCompletedHandler) { + self.handler = { _, customerInfo in handler(customerInfo) } + } + + init(handler: @escaping PurchaseCompletedHandler) { + self.handler = handler + } func body(content: Content) -> some View { content - .onPreferenceChange(PurchasedCustomerInfoPreferenceKey.self) { customerInfo in - if let customerInfo { - self.handler(customerInfo) + .onPreferenceChange(PurchasedResultPreferenceKey.self) { result in + if let result { + self.handler(result.transaction, result.customerInfo) } } } diff --git a/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewAPI.swift b/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewAPI.swift index ceaa515e94..08a61d7053 100644 --- a/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewAPI.swift +++ b/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewAPI.swift @@ -15,6 +15,7 @@ struct App: View { private var offering: Offering private var fonts: PaywallFontProvider private var completed: PurchaseOrRestoreCompletedHandler = { (_: CustomerInfo) in } + private var purchaseCompleted: PurchaseCompletedHandler = { (_: CustomerInfo, _: StoreTransaction?) in } var body: some View { self.content @@ -35,24 +36,29 @@ struct App: View { Text("") .presentPaywallIfNeeded(requiredEntitlementIdentifier: "") .presentPaywallIfNeeded(requiredEntitlementIdentifier: "", fonts: self.fonts) - .presentPaywallIfNeeded(requiredEntitlementIdentifier: "", purchaseCompleted: completed) - .presentPaywallIfNeeded(requiredEntitlementIdentifier: "", restoreCompleted: completed) - .presentPaywallIfNeeded(requiredEntitlementIdentifier: "", fonts: self.fonts, purchaseCompleted: completed) - .presentPaywallIfNeeded(requiredEntitlementIdentifier: "", fonts: self.fonts, restoreCompleted: completed) - .presentPaywallIfNeeded(requiredEntitlementIdentifier: "", fonts: self.fonts, purchaseCompleted: completed, - restoreCompleted: completed) + .presentPaywallIfNeeded(requiredEntitlementIdentifier: "", + purchaseCompleted: self.purchaseOrRestoreCompleted) + .presentPaywallIfNeeded(requiredEntitlementIdentifier: "", + restoreCompleted: self.purchaseOrRestoreCompleted) + .presentPaywallIfNeeded(requiredEntitlementIdentifier: "", fonts: self.fonts, + purchaseCompleted: self.purchaseOrRestoreCompleted) + .presentPaywallIfNeeded(requiredEntitlementIdentifier: "", fonts: self.fonts, + restoreCompleted: self.purchaseOrRestoreCompleted) + .presentPaywallIfNeeded(requiredEntitlementIdentifier: "", fonts: self.fonts, + purchaseCompleted: self.purchaseOrRestoreCompleted, + restoreCompleted: self.purchaseOrRestoreCompleted) .presentPaywallIfNeeded(fonts: self.fonts) { (_: CustomerInfo) in false } .presentPaywallIfNeeded(fonts: self.fonts) { (_: CustomerInfo) in false } purchaseCompleted: { - completed($0) + self.purchaseOrRestoreCompleted($0) } .presentPaywallIfNeeded(fonts: self.fonts) { (_: CustomerInfo) in false } purchaseCompleted: { - completed($0) + self.purchaseOrRestoreCompleted($0) } restoreCompleted: { - completed($0) + self.purchaseOrRestoreCompleted($0) } } @@ -61,37 +67,41 @@ struct App: View { Text("") .paywallFooter() .paywallFooter(fonts: self.fonts) - .paywallFooter(purchaseCompleted: completed) - .paywallFooter(fonts: self.fonts, purchaseCompleted: completed) + .paywallFooter(purchaseCompleted: self.purchaseOrRestoreCompleted) + .paywallFooter(fonts: self.fonts, purchaseCompleted: self.purchaseOrRestoreCompleted) .paywallFooter(condensed: true) .paywallFooter(condensed: true, fonts: self.fonts) - .paywallFooter(condensed: true, purchaseCompleted: completed) - .paywallFooter(condensed: true, fonts: self.fonts, purchaseCompleted: completed) + .paywallFooter(condensed: true, purchaseCompleted: self.purchaseOrRestoreCompleted) + .paywallFooter(condensed: true, fonts: self.fonts, purchaseCompleted: self.purchaseOrRestoreCompleted) .paywallFooter(condensed: true, fonts: self.fonts, - purchaseCompleted: completed, restoreCompleted: completed) + purchaseCompleted: self.purchaseOrRestoreCompleted, + restoreCompleted: self.purchaseOrRestoreCompleted) .paywallFooter(offering: offering) .paywallFooter(offering: offering, condensed: true) .paywallFooter(offering: offering, condensed: true, fonts: self.fonts) - .paywallFooter(offering: offering, condensed: true, purchaseCompleted: completed) - .paywallFooter(offering: offering, condensed: true, restoreCompleted: completed) - .paywallFooter(offering: offering, condensed: true, fonts: self.fonts, purchaseCompleted: completed) + .paywallFooter(offering: offering, condensed: true, purchaseCompleted: self.purchaseOrRestoreCompleted) + .paywallFooter(offering: offering, condensed: true, restoreCompleted: self.purchaseOrRestoreCompleted) .paywallFooter(offering: offering, condensed: true, fonts: self.fonts, - purchaseCompleted: completed, restoreCompleted: completed) + purchaseCompleted: self.purchaseOrRestoreCompleted) + .paywallFooter(offering: offering, condensed: true, fonts: self.fonts, + purchaseCompleted: self.purchaseOrRestoreCompleted, + restoreCompleted: self.purchaseOrRestoreCompleted) .paywallFooter(offering: offering) .paywallFooter(offering: offering, fonts: self.fonts) - .paywallFooter(offering: offering, purchaseCompleted: completed) - .paywallFooter(offering: offering, restoreCompleted: completed) - .paywallFooter(offering: offering, fonts: self.fonts, purchaseCompleted: completed) + .paywallFooter(offering: offering, purchaseCompleted: self.purchaseOrRestoreCompleted) + .paywallFooter(offering: offering, restoreCompleted: self.purchaseOrRestoreCompleted) + .paywallFooter(offering: offering, fonts: self.fonts, purchaseCompleted: self.purchaseOrRestoreCompleted) .paywallFooter(offering: offering, fonts: self.fonts, - purchaseCompleted: completed, restoreCompleted: completed) + purchaseCompleted: completed, restoreCompleted: self.purchaseOrRestoreCompleted) } @ViewBuilder var checkOnPurchaseAndRestoreCompleted: some View { Text("") - .onPurchaseCompleted(self.completed) - .onRestoreCompleted(self.completed) + .onPurchaseCompleted(self.purchaseOrRestoreCompleted) + .onPurchaseCompleted(self.purchaseCompleted) + .onRestoreCompleted(self.purchaseOrRestoreCompleted) } private func fontProviders() { diff --git a/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewControllerAPI.swift b/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewControllerAPI.swift index abe9f1c067..9037edcc37 100644 --- a/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewControllerAPI.swift +++ b/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewControllerAPI.swift @@ -23,6 +23,10 @@ final class Delegate: PaywallViewControllerDelegate { func paywallViewController(_ controller: PaywallViewController, didFinishPurchasingWith customerInfo: CustomerInfo) {} + func paywallViewController(_ controller: PaywallViewController, + didFinishPurchasingWith customerInfo: CustomerInfo, + transaction: StoreTransaction?) {} + func paywallViewController(_ controller: PaywallViewController, didFinishRestoringWith customerInfo: CustomerInfo) {} diff --git a/Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift b/Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift index 803839c466..b2ad9bde9e 100644 --- a/Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift +++ b/Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift @@ -70,6 +70,29 @@ class PurchaseCompletedHandlerTests: TestCase { expect(customerInfo).toEventually(be(TestData.customerInfo)) } + func testOnPurchaseCompletedWithTransaction() throws { + var result: (transaction: StoreTransaction?, customerInfo: CustomerInfo)? + + try PaywallView( + offering: Self.offering.withLocalImages, + customerInfo: TestData.customerInfo, + introEligibility: .producing(eligibility: .eligible), + purchaseHandler: Self.purchaseHandler + ) + .onPurchaseCompleted { transaction, customerInfo in + result = (transaction, customerInfo) + } + .addToHierarchy() + + Task { + _ = try await Self.purchaseHandler.purchase(package: Self.package) + } + + expect(result).toEventuallyNot(beNil()) + expect(result?.customerInfo) === TestData.customerInfo + expect(result?.transaction).to(beNil()) + } + func testOnRestoreCompleted() throws { var customerInfo: CustomerInfo? diff --git a/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift b/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift index dbc8d0032c..5e890de482 100644 --- a/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift +++ b/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift @@ -25,7 +25,7 @@ class PurchaseHandlerTests: TestCase { func testInitialState() async throws { let handler: PurchaseHandler = .mock() - expect(handler.purchasedCustomerInfo).to(beNil()) + expect(handler.purchaseResult).to(beNil()) expect(handler.restoredCustomerInfo).to(beNil()) expect(handler.purchased) == false expect(handler.restored) == false @@ -37,7 +37,7 @@ class PurchaseHandlerTests: TestCase { _ = try await handler.purchase(package: TestData.packageWithIntroOffer) - expect(handler.purchasedCustomerInfo) === TestData.customerInfo + expect(handler.purchaseResult?.customerInfo) === TestData.customerInfo expect(handler.restoredCustomerInfo).to(beNil()) expect(handler.purchased) == true expect(handler.actionInProgress) == false @@ -47,7 +47,7 @@ class PurchaseHandlerTests: TestCase { let handler: PurchaseHandler = .cancelling() _ = try await handler.purchase(package: TestData.packageWithIntroOffer) - expect(handler.purchasedCustomerInfo).to(beNil()) + expect(handler.purchaseResult).to(beNil()) expect(handler.purchased) == false expect(handler.actionInProgress) == false } @@ -61,7 +61,7 @@ class PurchaseHandlerTests: TestCase { expect(result.success) == false expect(handler.restored) == true expect(handler.restoredCustomerInfo) === TestData.customerInfo - expect(handler.purchasedCustomerInfo).to(beNil()) + expect(handler.purchaseResult).to(beNil()) expect(handler.actionInProgress) == false } From d7795f3b00d1a7155f1515bfd1e55a65f2e3c929 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Mon, 23 Oct 2023 10:37:55 -0700 Subject: [PATCH 2/2] Fixed API tester --- .../SwiftAPITester/PaywallViewAPI.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewAPI.swift b/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewAPI.swift index 08a61d7053..606acad259 100644 --- a/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewAPI.swift +++ b/Tests/APITesters/RevenueCatUIAPITester/SwiftAPITester/PaywallViewAPI.swift @@ -14,8 +14,8 @@ struct App: View { private var offering: Offering private var fonts: PaywallFontProvider - private var completed: PurchaseOrRestoreCompletedHandler = { (_: CustomerInfo) in } - private var purchaseCompleted: PurchaseCompletedHandler = { (_: CustomerInfo, _: StoreTransaction?) in } + private var purchaseOrRestoreCompleted: PurchaseOrRestoreCompletedHandler = { (_: CustomerInfo) in } + private var purchaseCompleted: PurchaseCompletedHandler = { (_: StoreTransaction?, _: CustomerInfo) in } var body: some View { self.content @@ -93,7 +93,8 @@ struct App: View { .paywallFooter(offering: offering, restoreCompleted: self.purchaseOrRestoreCompleted) .paywallFooter(offering: offering, fonts: self.fonts, purchaseCompleted: self.purchaseOrRestoreCompleted) .paywallFooter(offering: offering, fonts: self.fonts, - purchaseCompleted: completed, restoreCompleted: self.purchaseOrRestoreCompleted) + purchaseCompleted: self.purchaseOrRestoreCompleted, + restoreCompleted: self.purchaseOrRestoreCompleted) } @ViewBuilder