From c80491e098aff41328f43c8f30fe617a067cbb40 Mon Sep 17 00:00:00 2001 From: Alexander Repty Date: Thu, 4 Jun 2026 11:30:54 +0200 Subject: [PATCH] Track web checkout opened paywall event Add support for a "web checkout opened" flow: introduce WebCheckoutOpenedPreferenceKey and a published UUID (PurchaseHandler.webCheckoutOpened) to signal distinct web-checkout taps; add PurchaseHandler.signalWebCheckoutOpened to emit the preference and track a paywall_web_checkout_opened analytics event. Wire the signal from the purchase button, expose an onWebCheckoutOpened View modifier and WebCheckoutOpenedHandler, and add a PaywallViewController delegate callback. Extend PaywallEvent and networking enums to include webCheckoutOpened and add tests covering event tracking and PurchaseHandler behavior. Reset webCheckoutOpened on new sessions. --- RevenueCatUI/PaywallView.swift | 2 + .../Purchasing/PaywallEventTracker.swift | 21 ++++++++ RevenueCatUI/Purchasing/PurchaseHandler.swift | 38 ++++++++++++++ .../PurchaseButtonComponentView.swift | 2 + .../UIKit/PaywallViewController.swift | 12 +++++ .../View+PurchaseRestoreCompleted.swift | 40 +++++++++++++++ .../Events/FeatureEvents/FeatureEvent.swift | 1 + .../Networking/EventsRequest+Paywall.swift | 2 + Sources/Paywalls/Events/PaywallEvent.swift | 18 +++++-- .../PaywallViewAPI.swift | 2 + .../PaywallViewControllerAPI.swift | 2 + .../PaywallViewEventsTests.swift | 3 ++ .../Purchasing/PaywallEventTrackerTests.swift | 50 +++++++++++++++++++ .../Purchasing/PurchaseHandlerTests.swift | 42 ++++++++++++++++ 14 files changed, 231 insertions(+), 4 deletions(-) diff --git a/RevenueCatUI/PaywallView.swift b/RevenueCatUI/PaywallView.swift index 8a77ee35c2..60b8196d4a 100644 --- a/RevenueCatUI/PaywallView.swift +++ b/RevenueCatUI/PaywallView.swift @@ -512,6 +512,8 @@ struct LoadedOfferingPaywallView: View { value: self.purchaseHandler.purchaseError as NSError?) .preference(key: RestoreErrorPreferenceKey.self, value: self.purchaseHandler.restoreError as NSError?) + .preference(key: WebCheckoutOpenedPreferenceKey.self, + value: self.purchaseHandler.webCheckoutOpened) } @ViewBuilder diff --git a/RevenueCatUI/Purchasing/PaywallEventTracker.swift b/RevenueCatUI/Purchasing/PaywallEventTracker.swift index c8f62eae36..7b6833f0e3 100644 --- a/RevenueCatUI/Purchasing/PaywallEventTracker.swift +++ b/RevenueCatUI/Purchasing/PaywallEventTracker.swift @@ -157,6 +157,27 @@ final class PaywallEventTracker: @unchecked Sendable { return true } + /// Tracks a web checkout opened event. + /// - Parameters: + /// - package: The package associated with the web checkout CTA, if known. + /// - Returns: whether the event was tracked + @discardableResult + func trackWebCheckoutOpened(package: Package?, sessionID: SessionID) -> Bool { + guard let data = self.stateLock.withLock({ self.sessions[sessionID]?.eventData }) else { + Logger.warning(Strings.attempted_to_track_event_with_missing_data) + return false + } + + let eventData = data.withPurchaseInfo( + packageId: package?.identifier, + productId: package?.storeProduct.productIdentifier, + errorCode: nil, + errorMessage: nil + ) + self.track(.webCheckoutOpened(.init(), eventData)) + return true + } + /// Drops stored paywall state for `sessionID` (e.g. when the host resets purchase session state). func discardSession(sessionID: SessionID) { _ = self.stateLock.withLock { diff --git a/RevenueCatUI/Purchasing/PurchaseHandler.swift b/RevenueCatUI/Purchasing/PurchaseHandler.swift index 88a64c38fa..b1a30ec61b 100644 --- a/RevenueCatUI/Purchasing/PurchaseHandler.swift +++ b/RevenueCatUI/Purchasing/PurchaseHandler.swift @@ -100,6 +100,12 @@ final class PurchaseHandler: ObservableObject { @Published fileprivate(set) var consecutiveCancellationRequestID: UUID? + /// Set to a new UUID each time the user taps a web checkout CTA. + /// Used to propagate the ``WebCheckoutOpenedPreferenceKey`` so callers can distinguish + /// "web checkout opened" from a regular cancellation. + @Published + fileprivate(set) var webCheckoutOpened: UUID? + /// Whether a purchase was successfully completed in the current session. /// Convenience property for checking if we should skip exit offers. var hasPurchasedInSession: Bool { @@ -243,6 +249,7 @@ final class PurchaseHandler: ObservableObject { self.sessionPurchaseResult = nil self.consecutiveCancellationRequestID = nil self.purchaseResult = nil + self.webCheckoutOpened = nil self.activePaywallSessionID = nil } @@ -699,6 +706,24 @@ extension PurchaseHandler { return self.paywallEventTracker.createPurchaseInitiatedEvent(package: package, sessionID: sessionID) } + /// Signals that the user tapped a web checkout CTA and the paywall was dismissed to open the browser. + /// Sets a new UUID on ``webCheckoutOpened`` so the ``WebCheckoutOpenedPreferenceKey`` fires, + /// and tracks a ``PaywallEvent/webCheckoutOpened(_:_:)`` analytics event. + /// - Parameter package: The package associated with the web checkout CTA, if known. + func signalWebCheckoutOpened(package: Package?) { + self.webCheckoutOpened = UUID() + self.trackWebCheckoutOpened(package: package) + } + + /// - Returns: whether the event was tracked + @discardableResult + fileprivate func trackWebCheckoutOpened(package: Package?) -> Bool { + guard let sessionID = self.activePaywallSessionID else { + return false + } + return self.paywallEventTracker.trackWebCheckoutOpened(package: package, sessionID: sessionID) + } + /// Tracks a purchase error event. /// - Parameters: /// - package: The package that was being purchased @@ -965,6 +990,19 @@ struct RestoreErrorPreferenceKey: PreferenceKey { } +/// Preference key emitted whenever the user taps a web checkout CTA. +/// Each new UUID represents a distinct tap, allowing consecutive taps to both fire the callback. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct WebCheckoutOpenedPreferenceKey: PreferenceKey { + + static var defaultValue: UUID? + + static func reduce(value: inout UUID?, nextValue: () -> UUID?) { + value = nextValue() + } + +} + // MARK: Environment keys /// `EnvironmentKey` for storing closure triggered when paywall should be dismissed. diff --git a/RevenueCatUI/Templates/V2/Components/Packages/PurchaseButton/PurchaseButtonComponentView.swift b/RevenueCatUI/Templates/V2/Components/Packages/PurchaseButton/PurchaseButtonComponentView.swift index 18ceae6863..981a9748e6 100644 --- a/RevenueCatUI/Templates/V2/Components/Packages/PurchaseButton/PurchaseButtonComponentView.swift +++ b/RevenueCatUI/Templates/V2/Components/Packages/PurchaseButton/PurchaseButtonComponentView.swift @@ -191,6 +191,8 @@ struct PurchaseButtonComponentView: View { openURL: self.openURL, inAppBrowserURL: self.$inAppBrowserURL) + self.purchaseHandler.signalWebCheckoutOpened(package: self.packageContext.package) + if launchWebCheckout.autoDismiss { self.onDismiss() } diff --git a/RevenueCatUI/UIKit/PaywallViewController.swift b/RevenueCatUI/UIKit/PaywallViewController.swift index 0a974db3b5..02d3649ca2 100644 --- a/RevenueCatUI/UIKit/PaywallViewController.swift +++ b/RevenueCatUI/UIKit/PaywallViewController.swift @@ -691,6 +691,12 @@ public protocol PaywallViewControllerDelegate: AnyObject { optional func paywallViewController(_ controller: PaywallViewController, willPresentExitOfferController exitOfferController: PaywallViewController) + /// Notifies that the user tapped a web checkout CTA and has left the app to complete payment externally. + /// This is distinct from ``paywallViewControllerDidCancelPurchase(_:)`` — the user has not cancelled; + /// they have initiated a purchase flow outside of the app. + @objc(paywallViewControllerDidOpenWebCheckout:) + optional func paywallViewControllerDidOpenWebCheckout(_ controller: PaywallViewController) + /// Notifies that a purchase is about to be initiated, before the payment sheet is displayed. /// This allows the delegate to gate the purchase flow (e.g., requiring authentication). /// The `resume` closure **must** be called to either proceed (`true`) or cancel (`false`) the purchase. @@ -740,6 +746,10 @@ private extension PaywallViewController { guard let self else { return } self.delegate?.paywallViewControllerDidCancelPurchase?(self) }, + webCheckoutOpened: { [weak self] in + guard let self else { return } + self.delegate?.paywallViewControllerDidOpenWebCheckout?(self) + }, restoreCompleted: { [weak self] customerInfo in guard let self else { return } self.delegate?.paywallViewController?(self, didFinishRestoringWith: customerInfo) @@ -870,6 +880,7 @@ private struct PaywallContainerView: View { let purchaseStarted: PurchaseOfPackageStartedHandler let purchaseCompleted: PurchaseCompletedHandler let purchaseCancelled: PurchaseCancelledHandler + let webCheckoutOpened: WebCheckoutOpenedHandler let restoreCompleted: PurchaseOrRestoreCompletedHandler let purchaseFailure: PurchaseFailureHandler let restoreStarted: RestoreStartedHandler @@ -887,6 +898,7 @@ private struct PaywallContainerView: View { .onPurchaseStarted(self.purchaseStarted) .onPurchaseCompleted(self.purchaseCompleted) .onPurchaseCancelled(self.purchaseCancelled) + .onWebCheckoutOpened(self.webCheckoutOpened) .onPurchaseFailure(self.purchaseFailure) .onRestoreStarted(self.restoreStarted) .onRestoreCompleted(self.restoreCompleted) diff --git a/RevenueCatUI/View+PurchaseRestoreCompleted.swift b/RevenueCatUI/View+PurchaseRestoreCompleted.swift index 2269221c29..79597fbc9a 100644 --- a/RevenueCatUI/View+PurchaseRestoreCompleted.swift +++ b/RevenueCatUI/View+PurchaseRestoreCompleted.swift @@ -39,6 +39,11 @@ public typealias PurchaseOfPackageStartedHandler = @MainActor @Sendable (_ packa /// A closure used for notifying of purchase cancellation. public typealias PurchaseCancelledHandler = @MainActor @Sendable () -> Void +/// A closure invoked when the user taps a web checkout CTA and leaves the app to complete payment externally. +/// - Note: This is emitted for both `webCheckout` and `webProductSelection` purchase button methods. +/// It does **not** indicate that a purchase was completed; use `onPurchaseCompleted` for that. +public typealias WebCheckoutOpenedHandler = @MainActor @Sendable () -> Void + /// A closure used to perform custom purchase logic implemented by your app. /// - Parameters: /// - package: The package to be purchased. @@ -244,6 +249,25 @@ extension View { return self.modifier(OnPurchaseCancelledModifier(handler: handler)) } + /// Invokes the given closure when the user taps a web checkout CTA and is taken out of the app + /// to complete payment externally (e.g. via browser or in-app browser). + /// + /// This is distinct from ``onPurchaseCancelled(_:)`` — the user has not cancelled; + /// they have initiated a purchase flow outside of the app. + /// + /// Example: + /// ```swift + /// PaywallView() + /// .onWebCheckoutOpened { + /// print("User left to complete web checkout") + /// } + /// ``` + public func onWebCheckoutOpened( + _ handler: @escaping WebCheckoutOpenedHandler + ) -> some View { + return self.modifier(OnWebCheckoutOpenedModifier(handler: handler)) + } + /// Invokes the given closure when a restore begins. /// Example: /// ```swift @@ -514,6 +538,22 @@ private struct OnPurchaseCancelledModifier: ViewModifier { } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private struct OnWebCheckoutOpenedModifier: ViewModifier { + + let handler: WebCheckoutOpenedHandler + + func body(content: Content) -> some View { + content + .onPreferenceChange(WebCheckoutOpenedPreferenceKey.self) { uuid in + if uuid != nil { + self.handler() + } + } + } + +} + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) private struct OnRestoreStartedModifier: ViewModifier { diff --git a/Sources/Events/FeatureEvents/FeatureEvent.swift b/Sources/Events/FeatureEvents/FeatureEvent.swift index 4b867fac08..afe5b218e4 100644 --- a/Sources/Events/FeatureEvents/FeatureEvent.swift +++ b/Sources/Events/FeatureEvents/FeatureEvent.swift @@ -77,6 +77,7 @@ private extension PaywallEvent { case .purchaseInitiated: return "paywall_purchase_initiated" case .purchaseError: return "paywall_purchase_error" case .componentInteraction: return "paywall_component_interacted" + case .webCheckoutOpened: return "paywall_web_checkout_opened" } }() diff --git a/Sources/Paywalls/Events/Networking/EventsRequest+Paywall.swift b/Sources/Paywalls/Events/Networking/EventsRequest+Paywall.swift index d8d1ae0b3c..03488eec1c 100644 --- a/Sources/Paywalls/Events/Networking/EventsRequest+Paywall.swift +++ b/Sources/Paywalls/Events/Networking/EventsRequest+Paywall.swift @@ -96,6 +96,7 @@ extension FeatureEventsRequest.PaywallEvent { case purchaseInitiated = "paywall_purchase_initiated" case purchaseError = "paywall_purchase_error" case componentInteraction = "paywall_component_interacted" + case webCheckoutOpened = "paywall_web_checkout_opened" } @@ -185,6 +186,7 @@ private extension PaywallEvent { case .purchaseInitiated: return .purchaseInitiated case .purchaseError: return .purchaseError case .componentInteraction: return .componentInteraction + case .webCheckoutOpened: return .webCheckoutOpened } } diff --git a/Sources/Paywalls/Events/PaywallEvent.swift b/Sources/Paywalls/Events/PaywallEvent.swift index 387055b78d..007bd70703 100644 --- a/Sources/Paywalls/Events/PaywallEvent.swift +++ b/Sources/Paywalls/Events/PaywallEvent.swift @@ -74,7 +74,7 @@ import Foundation switch self { case .purchaseInitiated, .purchaseError: return false - case .impression, .cancel, .close, .exitOffer, .componentInteraction: + case .impression, .cancel, .close, .exitOffer, .componentInteraction, .webCheckoutOpened: return true } } @@ -83,7 +83,8 @@ import Foundation switch self { case .impression: return true - case .cancel, .close, .exitOffer, .componentInteraction, .purchaseInitiated, .purchaseError: + case .cancel, .close, .exitOffer, .componentInteraction, + .purchaseInitiated, .purchaseError, .webCheckoutOpened: return false } } @@ -109,6 +110,11 @@ import Foundation /// User interacted with a paywall control (tabs, carousel, non-purchase button, etc.). case componentInteraction(CreationData, Data, ComponentInteractionData) + /// The user tapped a web checkout CTA and left the app to complete payment externally. + /// Emitted for `webCheckout`, `webProductSelection`, and `customWebCheckout` button methods. + /// This does not indicate a completed purchase — use ``impression`` / purchase completion events for that. + case webCheckoutOpened(CreationData, Data) + } extension PaywallEvent { @@ -416,6 +422,7 @@ extension PaywallEvent { case let .purchaseInitiated(creationData, _): return creationData case let .purchaseError(creationData, _): return creationData case let .componentInteraction(creationData, _, _): return creationData + case let .webCheckoutOpened(creationData, _): return creationData } } @@ -429,13 +436,15 @@ extension PaywallEvent { case let .purchaseInitiated(_, data): return data case let .purchaseError(_, data): return data case let .componentInteraction(_, data, _): return data + case let .webCheckoutOpened(_, data): return data } } /// - Returns: the underlying ``PaywallEvent/ExitOfferData-swift.struct`` for exit offer events, nil otherwise. @_spi(Internal) public var exitOfferData: ExitOfferData? { switch self { - case .impression, .cancel, .close, .purchaseInitiated, .purchaseError, .componentInteraction: return nil + case .impression, .cancel, .close, .purchaseInitiated, .purchaseError, + .componentInteraction, .webCheckoutOpened: return nil case let .exitOffer(_, _, exitOfferData): return exitOfferData } } @@ -443,7 +452,8 @@ extension PaywallEvent { /// - Returns: control interaction payload for ``PaywallEvent/componentInteraction(_:_:_:)``, nil for other events. @_spi(Internal) public var componentInteractionData: ComponentInteractionData? { switch self { - case .impression, .cancel, .close, .exitOffer, .purchaseInitiated, .purchaseError: return nil + case .impression, .cancel, .close, .exitOffer, .purchaseInitiated, + .purchaseError, .webCheckoutOpened: return nil case let .componentInteraction(_, _, interactionData): return interactionData } } diff --git a/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallViewAPI.swift b/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallViewAPI.swift index 0a3f1c4e7d..415252cfe4 100644 --- a/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallViewAPI.swift +++ b/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallViewAPI.swift @@ -21,6 +21,7 @@ struct App: View { private var purchaseOfPackageStarted: PurchaseOfPackageStartedHandler = { (_: Package) in } private var purchaseCompleted: PurchaseCompletedHandler = { (_: StoreTransaction?, _: CustomerInfo) in } private var purchaseCancelled: PurchaseCancelledHandler = { () in } + private var webCheckoutOpened: WebCheckoutOpenedHandler = { } private var restoreStarted: RestoreStartedHandler = { } private var failureHandler: PurchaseFailureHandler = { (_: NSError) in } @@ -793,6 +794,7 @@ struct App: View { .onPurchaseCompleted(self.purchaseOrRestoreCompleted) .onPurchaseCompleted(self.purchaseCompleted) .onPurchaseCancelled(self.purchaseCancelled) + .onWebCheckoutOpened(self.webCheckoutOpened) .onRestoreStarted(self.restoreStarted) .onRestoreCompleted(self.purchaseOrRestoreCompleted) .onRequestedDismissal(self.requestedDismissal) diff --git a/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallViewControllerAPI.swift b/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallViewControllerAPI.swift index 6d619b975b..5677b73d5f 100644 --- a/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallViewControllerAPI.swift +++ b/Tests/APITesters/AllAPITests/RevenueCatUISwiftAPITester/PaywallViewControllerAPI.swift @@ -203,6 +203,8 @@ final class Delegate: PaywallViewControllerDelegate { func paywallViewControllerDidCancelPurchase(_ controller: PaywallViewController) {} + func paywallViewControllerDidOpenWebCheckout(_ controller: PaywallViewController) {} + func paywallViewController(_ controller: PaywallViewController, didFailPurchasingWith error: NSError) {} diff --git a/Tests/RevenueCatUITests/PaywallViewEventsTests.swift b/Tests/RevenueCatUITests/PaywallViewEventsTests.swift index 404c450383..0c88cc898b 100644 --- a/Tests/RevenueCatUITests/PaywallViewEventsTests.swift +++ b/Tests/RevenueCatUITests/PaywallViewEventsTests.swift @@ -213,6 +213,7 @@ private extension BasePaywallViewEventsTests { case .purchaseInitiated: break case .purchaseError: break case .componentInteraction: break + case .webCheckoutOpened: break } } @@ -262,6 +263,7 @@ private extension PaywallEvent { case purchaseInitiated case purchaseError case componentInteraction + case webCheckoutOpened } @@ -274,6 +276,7 @@ private extension PaywallEvent { case .purchaseInitiated: return .purchaseInitiated case .purchaseError: return .purchaseError case .componentInteraction: return .componentInteraction + case .webCheckoutOpened: return .webCheckoutOpened } } diff --git a/Tests/RevenueCatUITests/Purchasing/PaywallEventTrackerTests.swift b/Tests/RevenueCatUITests/Purchasing/PaywallEventTrackerTests.swift index 5202d3b1ac..e1b6d86974 100644 --- a/Tests/RevenueCatUITests/Purchasing/PaywallEventTrackerTests.swift +++ b/Tests/RevenueCatUITests/Purchasing/PaywallEventTrackerTests.swift @@ -407,6 +407,56 @@ class PaywallEventTrackerTests: TestCase { } } + func testTrackWebCheckoutOpenedAddsPurchaseInfoFromSession() async throws { + let (tracker, trackedEvents) = Self.makeTracker() + let sessionID = Self.eventData.sessionIdentifier + + expect(tracker.trackWebCheckoutOpened(package: TestData.packageWithIntroOffer, sessionID: sessionID)) == false + + tracker.trackPaywallImpression(Self.eventData) + + expect(tracker.trackWebCheckoutOpened(package: TestData.packageWithIntroOffer, sessionID: sessionID)) == true + + await expect(trackedEvents.value).toEventually(haveCount(2), timeout: .seconds(2)) + + let webCheckoutEvent = try XCTUnwrap(trackedEvents.value.first(where: { + if case .webCheckoutOpened = $0 { return true } + return false + })) + + if case .webCheckoutOpened = webCheckoutEvent { + expect(webCheckoutEvent.data.packageId) == TestData.packageWithIntroOffer.identifier + expect(webCheckoutEvent.data.productId) == TestData.packageWithIntroOffer.storeProduct.productIdentifier + expect(webCheckoutEvent.data.errorCode).to(beNil()) + expect(webCheckoutEvent.data.errorMessage).to(beNil()) + expect(webCheckoutEvent.data.sessionIdentifier) == Self.eventData.sessionIdentifier + } else { + fail("Expected webCheckoutOpened event") + } + } + + func testTrackWebCheckoutOpenedWithNilPackageOmitsPackageInfo() async throws { + let (tracker, trackedEvents) = Self.makeTracker() + let sessionID = Self.eventData.sessionIdentifier + + tracker.trackPaywallImpression(Self.eventData) + expect(tracker.trackWebCheckoutOpened(package: nil, sessionID: sessionID)) == true + + await expect(trackedEvents.value).toEventually(haveCount(2), timeout: .seconds(2)) + + let webCheckoutEvent = try XCTUnwrap(trackedEvents.value.first(where: { + if case .webCheckoutOpened = $0 { return true } + return false + })) + + if case .webCheckoutOpened = webCheckoutEvent { + expect(webCheckoutEvent.data.packageId).to(beNil()) + expect(webCheckoutEvent.data.productId).to(beNil()) + } else { + fail("Expected webCheckoutOpened event") + } + } + func testTrackComponentInteraction_IncludesPurchaseButtonMetadataWhenProvided() async throws { let (tracker, trackedEvents) = Self.makeTracker() let sessionID = Self.eventData.sessionIdentifier diff --git a/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift b/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift index c73f06b412..ee15128cad 100644 --- a/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift +++ b/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift @@ -38,6 +38,48 @@ class PurchaseHandlerTests: TestCase { expect(handler.actionInProgress) == false expect(handler.purchaseError).to(beNil()) expect(handler.restoreError).to(beNil()) + expect(handler.webCheckoutOpened).to(beNil()) + } + + func testSignalWebCheckoutOpenedSetsUUID() { + let handler: PurchaseHandler = .mock() + + expect(handler.webCheckoutOpened).to(beNil()) + handler.signalWebCheckoutOpened(package: nil) + expect(handler.webCheckoutOpened).toNot(beNil()) + } + + func testSignalWebCheckoutOpenedProducesDistinctUUIDsOnConsecutiveTaps() { + let handler: PurchaseHandler = .mock() + + handler.signalWebCheckoutOpened(package: nil) + let firstID = handler.webCheckoutOpened + + handler.signalWebCheckoutOpened(package: nil) + let secondID = handler.webCheckoutOpened + + expect(firstID).toNot(beNil()) + expect(secondID).toNot(beNil()) + expect(firstID) != secondID + } + + func testWebCheckoutOpenedIsResetOnNewSession() { + let handler: PurchaseHandler = .mock() + + handler.signalWebCheckoutOpened(package: nil) + expect(handler.webCheckoutOpened).toNot(beNil()) + + handler.resetForNewSession() + expect(handler.webCheckoutOpened).to(beNil()) + } + + func testWebCheckoutOpenedDoesNotAffectHasPurchasedInSession() { + let handler: PurchaseHandler = .mock() + + handler.signalWebCheckoutOpened(package: nil) + + expect(handler.hasPurchasedInSession) == false + expect(handler.purchaseResult).to(beNil()) } func testPurchaseSetsCustomerInfo() async throws {