From f75af6a148e3f156512f38f1828f7cae2fe8ec46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:44:58 +0100 Subject: [PATCH 1/4] Include product ID in Stripe checkout swipe-dismiss abandon When a user swipes to dismiss the Stripe checkout sheet before the JS stripe_checkout_abandon message fires, the fallback abandon path now uses the product ID from stripeCheckoutStart instead of an empty string. Co-Authored-By: Claude Opus 4.6 --- .../View Controller/PaywallViewController.swift | 12 +++++++++++- .../Message Handling/PaywallMessageHandler.swift | 2 ++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 32cff1623..b36e50b27 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -114,6 +114,10 @@ public class PaywallViewController: UIViewController, LoadingDelegate { /// Tracks whether explicit stripe_checkout_abandon was already received for this checkout flow. private var didReceiveStripeCheckoutAbandonMessage = false + /// The product ID from the most recent stripe_checkout_start message, used as a fallback + /// when the user swipe-dismisses the checkout sheet before stripe_checkout_abandon fires. + private var lastStripeCheckoutProductId: String? + /// Ensures Stripe checkout callbacks are forwarded to WebEntitlementRedeemer in order. private var previousStripeCheckoutTask: Task? @@ -1132,6 +1136,7 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { didRedeemSucceedDuringCheckout = false isCheckoutDismissedProgrammatically = false didReceiveStripeCheckoutAbandonMessage = false + lastStripeCheckoutProductId = nil transactionAbandonWorkItem?.cancel() transactionAbandonWorkItem = nil @@ -1159,7 +1164,7 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { guard let self = self else { return } Task { let event = InternalSuperwallEvent.Transaction( - state: .abandon(StoreProduct.blank()), + state: .abandon(StoreProduct.blank(productIdentifier: self.lastStripeCheckoutProductId ?? "")), paywallInfo: self.info, product: nil, transaction: nil, @@ -1178,6 +1183,7 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { self.didRedeemSucceedDuringCheckout = false self.isCheckoutDismissedProgrammatically = false self.didReceiveStripeCheckoutAbandonMessage = false + self.lastStripeCheckoutProductId = nil } checkoutVC.modalPresentationStyle = .pageSheet @@ -1196,6 +1202,10 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { #endif } + func handleStripeCheckoutStart(productId: String) { + lastStripeCheckoutProductId = productId + } + func handleStripeCheckoutSubmit(checkoutContextId: String, productId: String) { enqueueStripeCheckoutTask { paywall in await paywall.webEntitlementRedeemer.registerStripeCheckoutSubmit( diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift index 657bdff35..7c80fb898 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift @@ -23,6 +23,7 @@ protocol PaywallMessageHandlerDelegate: AnyObject { func presentSafariExternal(_ url: URL) func requestReview(type: ReviewType) func openPaymentSheet(_ url: URL) + func handleStripeCheckoutStart(productId: String) func handleStripeCheckoutSubmit(checkoutContextId: String, productId: String) func handleStripeCheckoutComplete( checkoutContextId: String, @@ -199,6 +200,7 @@ final class PaywallMessageHandler: WebEventDelegate { state: .start, productId: productId ) + delegate?.handleStripeCheckoutStart(productId: productId) case let .stripeCheckoutComplete(checkoutContextId, productId): trackStripeCheckoutEvent( state: .complete, From 8405571e0060d3507e9be03b4693e77ac676b68f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:00:53 +0100 Subject: [PATCH 2/4] Fix: capture product ID by value and add missing mock stub Co-Authored-By: Claude Opus 4.6 --- .../Paywall/View Controller/PaywallViewController.swift | 3 ++- .../Message Handling/PaywallMessageHandlerDelegateMock.swift | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index b36e50b27..84c4ca09c 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -1160,11 +1160,12 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { if !self.didRedeemSucceedDuringCheckout, !self.isCheckoutDismissedProgrammatically, !self.didReceiveStripeCheckoutAbandonMessage { + let abandonProductId = self.lastStripeCheckoutProductId ?? "" let workItem = DispatchWorkItem { [weak self] in guard let self = self else { return } Task { let event = InternalSuperwallEvent.Transaction( - state: .abandon(StoreProduct.blank(productIdentifier: self.lastStripeCheckoutProductId ?? "")), + state: .abandon(StoreProduct.blank(productIdentifier: abandonProductId)), paywallInfo: self.info, product: nil, transaction: nil, diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift index 22de441dd..116654ea6 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift @@ -93,6 +93,8 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { didOpenPaymentSheet = true } + func handleStripeCheckoutStart(productId: String) {} + func handleStripeCheckoutSubmit(checkoutContextId: String, productId: String) { stripeCheckoutSubmit = (checkoutContextId, productId) } From 3fcb3744cfec9ac763e8409de817249b4fa07cde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:23:51 +0100 Subject: [PATCH 3/4] Don't reset lastStripeCheckoutProductId in openPaymentSheet; capture productId in mock Co-Authored-By: Claude Opus 4.6 --- .../Paywall/View Controller/PaywallViewController.swift | 1 - .../PaywallMessageHandlerDelegateMock.swift | 6 +++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 84c4ca09c..0ce039533 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -1136,7 +1136,6 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { didRedeemSucceedDuringCheckout = false isCheckoutDismissedProgrammatically = false didReceiveStripeCheckoutAbandonMessage = false - lastStripeCheckoutProductId = nil transactionAbandonWorkItem?.cancel() transactionAbandonWorkItem = nil diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift index 116654ea6..c6c997dd6 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift @@ -93,7 +93,11 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { didOpenPaymentSheet = true } - func handleStripeCheckoutStart(productId: String) {} + var stripeCheckoutStartProductId: String? + + func handleStripeCheckoutStart(productId: String) { + stripeCheckoutStartProductId = productId + } func handleStripeCheckoutSubmit(checkoutContextId: String, productId: String) { stripeCheckoutSubmit = (checkoutContextId, productId) From b64a33b770c138dd6b6986cd9f868f6b00f47ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:09:33 +0100 Subject: [PATCH 4/4] Get product ID from openPaymentSheet message instead of stripeCheckoutStart stripe_checkout_start fires too late (from within the Stripe checkout page), so the product ID was always nil on swipe-dismiss. Now the product ID is passed via the open_url message with browserType payment_sheet, which arrives before the checkout sheet opens. Co-Authored-By: Claude Opus 4.6 --- .../View Controller/PaywallViewController.swift | 7 ++----- .../Web View/Message Handling/PaywallMessage.swift | 9 +++++++-- .../Message Handling/PaywallMessageHandler.swift | 12 +++++------- .../PaywallMessageHandlerDelegateMock.swift | 11 ++++------- 4 files changed, 18 insertions(+), 21 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 0ce039533..2155a56ab 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -1130,12 +1130,13 @@ extension PaywallViewController: UIAdaptivePresentationControllerDelegate { // MARK: - PaywallMessageHandlerDelegate extension PaywallViewController: PaywallMessageHandlerDelegate { - func openPaymentSheet(_ url: URL) { + func openPaymentSheet(_ url: URL, productId: String?) { #if !os(visionOS) // Reset flags when opening checkout didRedeemSucceedDuringCheckout = false isCheckoutDismissedProgrammatically = false didReceiveStripeCheckoutAbandonMessage = false + lastStripeCheckoutProductId = productId transactionAbandonWorkItem?.cancel() transactionAbandonWorkItem = nil @@ -1202,10 +1203,6 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { #endif } - func handleStripeCheckoutStart(productId: String) { - lastStripeCheckoutProductId = productId - } - func handleStripeCheckoutSubmit(checkoutContextId: String, productId: String) { enqueueStripeCheckoutTask { paywall in await paywall.webEntitlementRedeemer.registerStripeCheckoutSubmit( diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift index cb1c3f5f5..d729ced7a 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift @@ -50,7 +50,7 @@ enum PaywallMessage: Decodable, Equatable { case restore case openUrl(_ url: URL) case openUrlInSafari(_ url: URL) - case openPaymentSheet(_ url: URL) + case openPaymentSheet(_ url: URL, productId: String?) case openDeepLink(url: URL) case purchase(productId: String, shouldDismiss: Bool) case custom(data: String) @@ -184,7 +184,12 @@ enum PaywallMessage: Decodable, Equatable { // On visionOS, always use openUrl instead of payment sheet self = .openUrl(url) #else - self = browserType == "payment_sheet" ? .openPaymentSheet(url) : .openUrl(url) + if browserType == "payment_sheet" { + let productId = try? values.decode(String.self, forKey: .productId) + self = .openPaymentSheet(url, productId: productId) + } else { + self = .openUrl(url) + } #endif return } diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift index 7c80fb898..11bc4dd17 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift @@ -22,8 +22,7 @@ protocol PaywallMessageHandlerDelegate: AnyObject { func presentSafariInApp(_ url: URL) func presentSafariExternal(_ url: URL) func requestReview(type: ReviewType) - func openPaymentSheet(_ url: URL) - func handleStripeCheckoutStart(productId: String) + func openPaymentSheet(_ url: URL, productId: String?) func handleStripeCheckoutSubmit(checkoutContextId: String, productId: String) func handleStripeCheckoutComplete( checkoutContextId: String, @@ -177,8 +176,8 @@ final class PaywallMessageHandler: WebEventDelegate { openUrl(url) case .openUrlInSafari(let url): openUrlInSafari(url) - case .openPaymentSheet(let url): - openPaymentSheet(url) + case let .openPaymentSheet(url, productId): + openPaymentSheet(url, productId: productId) case .openDeepLink(let url): openDeepLink(url) case .restore: @@ -200,7 +199,6 @@ final class PaywallMessageHandler: WebEventDelegate { state: .start, productId: productId ) - delegate?.handleStripeCheckoutStart(productId: productId) case let .stripeCheckoutComplete(checkoutContextId, productId): trackStripeCheckoutEvent( state: .complete, @@ -476,13 +474,13 @@ final class PaywallMessageHandler: WebEventDelegate { delegate?.presentSafariExternal(url) } - private func openPaymentSheet(_ url: URL) { + private func openPaymentSheet(_ url: URL, productId: String?) { detectHiddenPaywallEvent( "openPaymentSheet", userInfo: ["url": url] ) hapticFeedback() - delegate?.openPaymentSheet(url) + delegate?.openPaymentSheet(url, productId: productId) } private func openDeepLink(_ url: URL) { diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift index c6c997dd6..6dd9f49cb 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift @@ -89,14 +89,11 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { didRequestReview = true } - func openPaymentSheet(_ url: URL) { - didOpenPaymentSheet = true - } + var openPaymentSheetProductId: String? - var stripeCheckoutStartProductId: String? - - func handleStripeCheckoutStart(productId: String) { - stripeCheckoutStartProductId = productId + func openPaymentSheet(_ url: URL, productId: String?) { + didOpenPaymentSheet = true + openPaymentSheetProductId = productId } func handleStripeCheckoutSubmit(checkoutContextId: String, productId: String) {