Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions RevenueCatUI/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions RevenueCatUI/Purchasing/PaywallEventTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
38 changes: 38 additions & 0 deletions RevenueCatUI/Purchasing/PurchaseHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -243,6 +249,7 @@ final class PurchaseHandler: ObservableObject {
self.sessionPurchaseResult = nil
self.consecutiveCancellationRequestID = nil
self.purchaseResult = nil
self.webCheckoutOpened = nil
self.activePaywallSessionID = nil
}

Expand Down Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale web checkout on reappear

Medium Severity

webCheckoutOpened is cleared in resetForNewSession, which SwiftUI paywall presentation often never calls on a new show. A new trackPaywallImpression does not clear it either, so a leftover UUID can make onWebCheckoutOpened run when the paywall reappears without a new web checkout tap.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c80491e. Configure here.

/// 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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
12 changes: 12 additions & 0 deletions RevenueCatUI/UIKit/PaywallViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions RevenueCatUI/View+PurchaseRestoreCompleted.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {

Expand Down
1 change: 1 addition & 0 deletions Sources/Events/FeatureEvents/FeatureEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

}

Expand Down Expand Up @@ -185,6 +186,7 @@ private extension PaywallEvent {
case .purchaseInitiated: return .purchaseInitiated
case .purchaseError: return .purchaseError
case .componentInteraction: return .componentInteraction
case .webCheckoutOpened: return .webCheckoutOpened
}

}
Expand Down
18 changes: 14 additions & 4 deletions Sources/Paywalls/Events/PaywallEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
}

Expand All @@ -429,21 +436,24 @@ 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
}
}

/// - 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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ final class Delegate: PaywallViewControllerDelegate {

func paywallViewControllerDidCancelPurchase(_ controller: PaywallViewController) {}

func paywallViewControllerDidOpenWebCheckout(_ controller: PaywallViewController) {}

func paywallViewController(_ controller: PaywallViewController,
didFailPurchasingWith error: NSError) {}

Expand Down
3 changes: 3 additions & 0 deletions Tests/RevenueCatUITests/PaywallViewEventsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ private extension BasePaywallViewEventsTests {
case .purchaseInitiated: break
case .purchaseError: break
case .componentInteraction: break
case .webCheckoutOpened: break
}
}

Expand Down Expand Up @@ -262,6 +263,7 @@ private extension PaywallEvent {
case purchaseInitiated
case purchaseError
case componentInteraction
case webCheckoutOpened

}

Expand All @@ -274,6 +276,7 @@ private extension PaywallEvent {
case .purchaseInitiated: return .purchaseInitiated
case .purchaseError: return .purchaseError
case .componentInteraction: return .componentInteraction
case .webCheckoutOpened: return .webCheckoutOpened
}
}

Expand Down
Loading