From a6820d88b475ec5ef1139d5215c31ff670861458 Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Thu, 12 Feb 2026 21:23:05 +0100 Subject: [PATCH 01/11] feat: add new app2web polling flow --- .../Models/Web2App/RedeemRequest.swift | 6 + Sources/SuperwallKit/Network/Endpoint.swift | 13 + Sources/SuperwallKit/Network/Network.swift | 7 + .../PaywallViewController.swift | 47 +- .../Message Handling/PaywallMessage.swift | 51 +++ .../PaywallMessageHandler.swift | 26 ++ .../Storage/Cache/CacheKeys.swift | 8 + .../Products/StoreProduct/StoreProduct.swift | 11 +- .../Web/WebEntitlementRedeemer.swift | 250 ++++++++++- .../Network/NetworkMock.swift | 30 ++ .../Network/NetworkTests.swift | 28 ++ .../PaywallMessageHandlerDelegateMock.swift | 19 + .../PaywallMessageHandlerTests.swift | 218 +++++++++ .../Web/MockSuperwallDelegate.swift | 9 + .../Web/WebEntitlementRedeemerTests.swift | 422 ++++++++++++++++++ 15 files changed, 1138 insertions(+), 7 deletions(-) diff --git a/Sources/SuperwallKit/Models/Web2App/RedeemRequest.swift b/Sources/SuperwallKit/Models/Web2App/RedeemRequest.swift index d3dc1dd080..e6b1062982 100644 --- a/Sources/SuperwallKit/Models/Web2App/RedeemRequest.swift +++ b/Sources/SuperwallKit/Models/Web2App/RedeemRequest.swift @@ -19,3 +19,9 @@ struct TransactionReceipt: Encodable { let type = "IOS" let jwsRepresentation: String } + +struct PollRedemptionResultRequest: Encodable { + let checkoutContextId: String + let deviceId: String + let appUserId: String? +} diff --git a/Sources/SuperwallKit/Network/Endpoint.swift b/Sources/SuperwallKit/Network/Endpoint.swift index 1d80a87012..c754d168e0 100644 --- a/Sources/SuperwallKit/Network/Endpoint.swift +++ b/Sources/SuperwallKit/Network/Endpoint.swift @@ -371,6 +371,19 @@ extension Endpoint where method: .post ) } + + static func pollRedemptionResult(request: PollRedemptionResultRequest) -> Self { + let bodyData = try? JSONEncoder().encode(request) + + return Endpoint( + components: Components( + host: .web2app, + path: "checkout/session/poll-redemption-result", + bodyData: bodyData + ), + method: .post + ) + } } extension Endpoint where diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index 26ff43e315..ae630e119d 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -334,6 +334,13 @@ class Network { ) } + func pollRedemptionResult(request: PollRedemptionResultRequest) async throws -> RedeemResponse { + return try await urlSession.request( + .pollRedemptionResult(request: request), + data: SuperwallRequestData(factory: factory) + ) + } + func getEntitlements( appUserId: String?, deviceId: String diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index f53e221f0c..f7a7b5fbdf 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -111,6 +111,9 @@ public class PaywallViewController: UIViewController, LoadingDelegate { /// Tracks if checkout is being dismissed programmatically (e.g., via closeSafari). private var isCheckoutDismissedProgrammatically = false + /// Tracks whether explicit stripe_checkout_abandon was already received for this checkout flow. + private var didReceiveStripeCheckoutAbandonMessage = false + /// Manages intro offer eligibility tokens for SK2 purchases on iOS 18.2+ let introOfferTokenManager: IntroOfferTokenManager @@ -1075,6 +1078,7 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { // Reset flags when opening checkout didRedeemSucceedDuringCheckout = false isCheckoutDismissedProgrammatically = false + didReceiveStripeCheckoutAbandonMessage = false transactionAbandonWorkItem?.cancel() transactionAbandonWorkItem = nil @@ -1094,8 +1098,10 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { // Only track abandon if: // 1. Redeem did NOT succeed // 2. Dismissal was NOT programmatic (user dismissed it) + // 3. No stripe checkout abandon message was received if !self.didRedeemSucceedDuringCheckout, - !self.isCheckoutDismissedProgrammatically { + !self.isCheckoutDismissedProgrammatically, + !self.didReceiveStripeCheckoutAbandonMessage { let workItem = DispatchWorkItem { [weak self] in guard let self = self else { return } Task { @@ -1118,6 +1124,7 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { // Reset flags after handling self.didRedeemSucceedDuringCheckout = false self.isCheckoutDismissedProgrammatically = false + self.didReceiveStripeCheckoutAbandonMessage = false } checkoutVC.modalPresentationStyle = .pageSheet @@ -1136,6 +1143,44 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { #endif } + func handleStripeCheckoutStart(checkoutContextId: String, productId: String) { + Task { + await webEntitlementRedeemer.registerStripeCheckoutStart( + contextId: checkoutContextId, + productId: productId + ) + } + } + + func handleStripeCheckoutComplete( + swCheckoutId: String, + checkoutContextId: String, + productId: String + ) { + _ = swCheckoutId // Included for analytics parity from paywall events. + didReceiveStripeCheckoutAbandonMessage = true + loadingState = .manualLoading + closeSafari() + + Task { + await webEntitlementRedeemer.handleStripeCheckoutComplete( + contextId: checkoutContextId, + productId: productId + ) + } + } + + func handleStripeCheckoutAbandon(checkoutContextId: String, productId: String) { + _ = checkoutContextId // Context is persisted by start/complete and intentionally not cleared on abandon. + didReceiveStripeCheckoutAbandonMessage = true + transactionAbandonWorkItem?.cancel() + transactionAbandonWorkItem = nil + + Task { + await webEntitlementRedeemer.handleStripeCheckoutAbandon(productId: productId) + } + } + func eventDidOccur(_ paywallEvent: PaywallWebEvent) { Task { await eventDelegate?.eventDidOccur( 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 a6905fcd2c..a79a3fe777 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 @@ -56,6 +56,14 @@ enum PaywallMessage: Decodable, Equatable { case customPlacement(name: String, params: JSON) case userAttributesUpdated(attributes: JSON) case initiateWebCheckout(contextId: String) + case stripeCheckoutStart(checkoutContextId: String, productId: String) + case stripeCheckoutComplete( + swCheckoutId: String, + checkoutContextId: String, + productId: String + ) + case stripeCheckoutFail(checkoutContextId: String, productId: String) + case stripeCheckoutAbandon(checkoutContextId: String, productId: String) case requestStoreReview(ReviewType) case requestPermission(permissionType: PermissionType, requestId: String) case requestCallback( @@ -102,6 +110,10 @@ enum PaywallMessage: Decodable, Equatable { case customPlacement = "custom_placement" case userAttributesUpdated = "user_attribute_updated" case initiateWebCheckout = "initiate_web_checkout" + case stripeCheckoutStart = "stripe_checkout_start" + case stripeCheckoutComplete = "stripe_checkout_complete" + case stripeCheckoutFail = "stripe_checkout_fail" + case stripeCheckoutAbandon = "stripe_checkout_abandon" case requestStoreReview = "request_store_review" case scheduleNotification = "schedule_notification" case requestPermission = "request_permission" @@ -125,6 +137,7 @@ enum PaywallMessage: Decodable, Equatable { case reviewType case browserType case checkoutContextId + case swCheckoutId case type case title case subtitle @@ -207,6 +220,44 @@ enum PaywallMessage: Decodable, Equatable { self = .initiateWebCheckout(contextId: checkoutContextId) return } + case .stripeCheckoutStart: + if let checkoutContextId = try? values.decode(String.self, forKey: .checkoutContextId), + let productId = try? values.decode(String.self, forKey: .productId) { + self = .stripeCheckoutStart( + checkoutContextId: checkoutContextId, + productId: productId + ) + return + } + case .stripeCheckoutComplete: + if let swCheckoutId = try? values.decode(String.self, forKey: .swCheckoutId), + let checkoutContextId = try? values.decode(String.self, forKey: .checkoutContextId), + let productId = try? values.decode(String.self, forKey: .productId) { + self = .stripeCheckoutComplete( + swCheckoutId: swCheckoutId, + checkoutContextId: checkoutContextId, + productId: productId + ) + return + } + case .stripeCheckoutFail: + if let checkoutContextId = try? values.decode(String.self, forKey: .checkoutContextId), + let productId = try? values.decode(String.self, forKey: .productId) { + self = .stripeCheckoutFail( + checkoutContextId: checkoutContextId, + productId: productId + ) + return + } + case .stripeCheckoutAbandon: + if let checkoutContextId = try? values.decode(String.self, forKey: .checkoutContextId), + let productId = try? values.decode(String.self, forKey: .productId) { + self = .stripeCheckoutAbandon( + checkoutContextId: checkoutContextId, + productId: productId + ) + return + } case .requestStoreReview: if let reviewType = try? values.decode(ReviewType.self, forKey: .reviewType) { self = .requestStoreReview(reviewType) 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 893f092870..9b698e1a46 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,13 @@ protocol PaywallMessageHandlerDelegate: AnyObject { func presentSafariExternal(_ url: URL) func requestReview(type: ReviewType) func openPaymentSheet(_ url: URL) + func handleStripeCheckoutStart(checkoutContextId: String, productId: String) + func handleStripeCheckoutComplete( + swCheckoutId: String, + checkoutContextId: String, + productId: String + ) + func handleStripeCheckoutAbandon(checkoutContextId: String, productId: String) } @MainActor @@ -187,6 +194,25 @@ final class PaywallMessageHandler: WebEventDelegate { // No-op: This is only here for backwards compatibility so that we don't log // and error when decoding the message. break + case let .stripeCheckoutStart(checkoutContextId, productId): + delegate?.handleStripeCheckoutStart( + checkoutContextId: checkoutContextId, + productId: productId + ) + case let .stripeCheckoutComplete(swCheckoutId, checkoutContextId, productId): + delegate?.handleStripeCheckoutComplete( + swCheckoutId: swCheckoutId, + checkoutContextId: checkoutContextId, + productId: productId + ) + case .stripeCheckoutFail: + // No-op: don't clear checkout context on failure + break + case let .stripeCheckoutAbandon(checkoutContextId, productId): + delegate?.handleStripeCheckoutAbandon( + checkoutContextId: checkoutContextId, + productId: productId + ) case .requestStoreReview(let reviewType): requestReview(type: reviewType) case let .scheduleNotification(type, title, subtitle, body, delay): diff --git a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift index dd3db6b81e..48496c2cc3 100644 --- a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift +++ b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift @@ -233,6 +233,14 @@ enum LastWebEntitlementsFetchDate: Storable { typealias Value = Date } +enum PendingStripeCheckoutPollStorage: Storable { + static var key: String { + "store.PendingStripeCheckoutPollStorage" + } + static var directory: SearchPathDirectory = .userSpecificDocuments + typealias Value = PendingStripeCheckoutPollState +} + enum LatestCustomerInfo: Storable { static var key: String { "store.CustomerInfo" diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift index 2c4c3174d5..2c4315cf14 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift @@ -388,11 +388,16 @@ public final class StoreProduct: NSObject, StoreProductType, Sendable { static func blank() -> StoreProduct { return StoreProduct(BlankStoreProduct()) } + + /// Creates a blank StoreProduct with a specific product identifier. + static func blank(productIdentifier: String) -> StoreProduct { + return StoreProduct(BlankStoreProduct(productIdentifier: productIdentifier)) + } } // MARK: - Blank StoreProduct Implementation private struct BlankStoreProduct: StoreProductType { - var productIdentifier: String { "" } + let productIdentifier: String var entitlements: Set { [] } var price: Decimal { 0 } var subscriptionGroupIdentifier: String? { nil } @@ -442,4 +447,8 @@ private struct BlankStoreProduct: StoreProductType { var isFamilyShareable: Bool { false } func trialPeriodPricePerUnit(_ unit: SubscriptionPeriod.Unit) -> String { "" } + + init(productIdentifier: String = "") { + self.productIdentifier = productIdentifier + } } diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index 5ea06219a3..96d9f51846 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -9,6 +9,36 @@ import UIKit import Foundation +struct PendingStripeCheckoutPollState: Codable, Equatable { + static let defaultForegroundAttempts = 5 + + let checkoutContextId: String + let productId: String + let remainingForegroundAttempts: Int + let updatedAt: Date + + init( + checkoutContextId: String, + productId: String, + remainingForegroundAttempts: Int = defaultForegroundAttempts, + updatedAt: Date = Date() + ) { + self.checkoutContextId = checkoutContextId + self.productId = productId + self.remainingForegroundAttempts = remainingForegroundAttempts + self.updatedAt = updatedAt + } + + func consumingForegroundAttempt() -> PendingStripeCheckoutPollState { + PendingStripeCheckoutPollState( + checkoutContextId: checkoutContextId, + productId: productId, + remainingForegroundAttempts: max(remainingForegroundAttempts - 1, 0), + updatedAt: Date() + ) + } +} + actor WebEntitlementRedeemer { private unowned let network: Network private unowned let storage: Storage @@ -18,6 +48,7 @@ actor WebEntitlementRedeemer { private unowned let receiptManager: ReceiptManager private unowned let factory: Factory private var isProcessing = false + private var activeStripePollContextId: String? private var superwall: Superwall? typealias Factory = WebEntitlementFactory & OptionsFactory @@ -25,6 +56,23 @@ actor WebEntitlementRedeemer { & ConfigManagerFactory & HasExternalPurchaseControllerFactory + private enum StripePollTrigger: String { + case checkoutComplete = "checkout_complete" + case foreground = "foreground" + } + + private enum StripePollOutcome { + case redeemed + case noRedemptionFound + case requestFailed + case skippedInFlight + } + + private enum RedemptionCallbackMode { + case legacy + case pollFakeCompatibility + } + var isCurrentlyProcessing: Bool { isProcessing } @@ -81,6 +129,104 @@ actor WebEntitlementRedeemer { name: UIApplication.willEnterForegroundNotification, object: nil ) + + // Also check once on SDK initialization so pending Stripe checkouts can be + // recovered on cold launch. + Task { + if await factory.makeConfigManager() == nil { + return + } + await pollPendingStripeCheckoutOnForegroundIfNeeded() + } + } + + func registerStripeCheckoutStart( + contextId: String, + productId: String + ) { + savePendingStripeCheckoutState( + .init( + checkoutContextId: contextId, + productId: productId + ) + ) + } + + func handleStripeCheckoutComplete( + contextId: String, + productId: String + ) async { + savePendingStripeCheckoutState( + .init( + checkoutContextId: contextId, + productId: productId + ) + ) + + let outcome = await pollStripeRedemptionResult( + contextId: contextId, + productId: productId, + trigger: .checkoutComplete + ) + + if outcome != .redeemed { + let superwall = self.superwall ?? Superwall.shared + await MainActor.run { + superwall.paywallViewController?.loadingState = .ready + } + } + } + + func handleStripeCheckoutAbandon(productId: String) async { + let superwall = superwall ?? Superwall.shared + let product = StoreProduct.blank(productIdentifier: productId) + let paywallInfo = await MainActor.run { superwall.paywallViewController?.info ?? .empty() } + + let event = InternalSuperwallEvent.Transaction( + state: .abandon(product), + paywallInfo: paywallInfo, + product: nil, + transaction: nil, + source: .internal, + isObserved: false, + storeKitVersion: nil, + store: .stripe + ) + await superwall.track(event) + } + + func pollPendingStripeCheckoutOnForegroundIfNeeded() async { + guard let pendingState = pendingStripeCheckoutState else { + return + } + guard pendingState.remainingForegroundAttempts > 0 else { + clearPendingStripeCheckoutState() + return + } + + let outcome = await pollStripeRedemptionResult( + contextId: pendingState.checkoutContextId, + productId: pendingState.productId, + trigger: .foreground + ) + + // Consume foreground attempts after each trigger completes, except when skipped + // due to an existing in-flight poll. + guard outcome != .skippedInFlight else { + return + } + + guard let latestState = pendingStripeCheckoutState, + latestState.checkoutContextId == pendingState.checkoutContextId else { + return + } + + let updatedState = latestState.consumingForegroundAttempt() + if updatedState.remainingForegroundAttempts <= 0 { + clearPendingStripeCheckoutState() + } else { + savePendingStripeCheckoutState(updatedState) + } } func redeem( @@ -128,7 +274,8 @@ actor WebEntitlementRedeemer { await handleRedemptionSuccess( response: response, type: type, - superwall: superwall + superwall: superwall, + callbackMode: .legacy ) } catch { await handleRedemptionFailure( @@ -208,7 +355,8 @@ actor WebEntitlementRedeemer { private func handleRedemptionSuccess( response: RedeemResponse, type: RedeemType, - superwall: Superwall + superwall: Superwall, + callbackMode: RedemptionCallbackMode ) async { storage.save(Date(), forType: LastWebEntitlementsFetchDate.self) @@ -255,7 +403,8 @@ actor WebEntitlementRedeemer { response: response, allEntitlementIds: Set(allEntitlements.map { $0.id }), paywallEntitlementIds: paywallEntitlementIds, - superwall: superwall + superwall: superwall, + callbackMode: callbackMode ) } } @@ -316,7 +465,8 @@ actor WebEntitlementRedeemer { response: RedeemResponse, allEntitlementIds: Set, paywallEntitlementIds: Set, - superwall: Superwall + superwall: Superwall, + callbackMode: RedemptionCallbackMode ) async { guard let codeResult = response.results.first(where: { $0.code == code }) else { return } @@ -324,6 +474,12 @@ actor WebEntitlementRedeemer { let showConfirmation = superwallOptions.paywalls.shouldShowWebPurchaseConfirmationAlert func afterRedeem() async { + if callbackMode == .pollFakeCompatibility { + await self.delegate.willRedeemLink() + try? await Task.sleep(nanoseconds: 200_000_000) + await self.delegate.didRedeemLink(result: codeResult) + } + if let paywallVc = superwall.paywallViewController, !paywallEntitlementIds.isEmpty, paywallEntitlementIds.subtracting(allEntitlementIds).isEmpty, @@ -334,7 +490,10 @@ actor WebEntitlementRedeemer { await MainActor.run { superwall.paywallViewController?.loadingState = .ready } - await self.delegate.didRedeemLink(result: codeResult) + + if callbackMode == .legacy { + await self.delegate.didRedeemLink(result: codeResult) + } } if showConfirmation, @@ -430,12 +589,93 @@ actor WebEntitlementRedeemer { ) } + private var pendingStripeCheckoutState: PendingStripeCheckoutPollState? { + storage.get(PendingStripeCheckoutPollStorage.self) + } + + private func savePendingStripeCheckoutState(_ state: PendingStripeCheckoutPollState) { + storage.save(state, forType: PendingStripeCheckoutPollStorage.self) + } + + private func clearPendingStripeCheckoutState() { + storage.delete(PendingStripeCheckoutPollStorage.self) + } + + private func createPollRedemptionRequest( + contextId: String + ) -> PollRedemptionResultRequest { + return PollRedemptionResultRequest( + checkoutContextId: contextId, + deviceId: factory.makeDeviceId(), + appUserId: factory.makeAppUserId() + ) + } + + private func pollStripeRedemptionResult( + contextId: String, + productId: String, + trigger: StripePollTrigger + ) async -> StripePollOutcome { + if activeStripePollContextId != nil { + return .skippedInFlight + } + + activeStripePollContextId = contextId + defer { + activeStripePollContextId = nil + } + + let request = createPollRedemptionRequest(contextId: contextId) + let retryBackoffsNs: [UInt64] = [1_000_000_000, 2_000_000_000, 4_000_000_000] + + for attempt in 0...retryBackoffsNs.count { + do { + let response = try await network.pollRedemptionResult(request: request) + + guard let code = response.results.first?.code else { + if attempt < retryBackoffsNs.count { + try? await Task.sleep(nanoseconds: retryBackoffsNs[attempt]) + continue + } + return .noRedemptionFound + } + + let superwall = superwall ?? Superwall.shared + await handleRedemptionSuccess( + response: response, + type: .code(code), + superwall: superwall, + callbackMode: .pollFakeCompatibility + ) + clearPendingStripeCheckoutState() + return .redeemed + } catch { + Logger.debug( + logLevel: .warn, + scope: .webEntitlements, + message: "Stripe poll-redemption-result failed", + info: [ + "checkout_context_id": contextId, + "product_id": productId, + "trigger": trigger.rawValue, + "attempt": attempt + 1 + ], + error: error + ) + return .requestFailed + } + } + + return .noRedemptionFound + } + @objc nonisolated private func handleAppForeground() { Task { if await factory.makeConfigManager() == nil { return } + await pollPendingStripeCheckoutOnForegroundIfNeeded() await pollWebEntitlements() } } diff --git a/Tests/SuperwallKitTests/Network/NetworkMock.swift b/Tests/SuperwallKitTests/Network/NetworkMock.swift index be8a1f96f0..dff257b3d3 100644 --- a/Tests/SuperwallKitTests/Network/NetworkMock.swift +++ b/Tests/SuperwallKitTests/Network/NetworkMock.swift @@ -19,10 +19,15 @@ final class NetworkMock: Network { var getEntitlementsResponse: EntitlementsResponse? var redeemError: Error? var redeemRequest: RedeemRequest? + var pollRedemptionResultRequest: PollRedemptionResultRequest? + var pollRedemptionResultResponses: [Result] = [] var getIntroOfferTokenResult: Result<[String: IntroOfferToken], Error>? var getIntroOfferTokenCallCount = 0 var redeemDelay: TimeInterval = 0 var redeemCallCount = 0 + var pollRedemptionResultCallCount = 0 + var onRedeemEntitlements: (() -> Void)? + var onPollRedemptionResult: (() -> Void)? override func sendSessionEvents(_ session: SessionEventsRequest) async { sentSessionEvents = session @@ -58,6 +63,7 @@ final class NetworkMock: Network { override func redeemEntitlements(request: RedeemRequest) async throws -> RedeemResponse { redeemRequest = request redeemCallCount += 1 + onRedeemEntitlements?() if redeemDelay > 0 { try? await Task.sleep(nanoseconds: UInt64(redeemDelay * 1_000_000_000)) @@ -83,6 +89,30 @@ final class NetworkMock: Network { throw NetworkError.unknown } + override func pollRedemptionResult(request: PollRedemptionResultRequest) async throws -> RedeemResponse { + pollRedemptionResultRequest = request + pollRedemptionResultCallCount += 1 + onPollRedemptionResult?() + + if !pollRedemptionResultResponses.isEmpty { + let result = pollRedemptionResultResponses.removeFirst() + switch result { + case .success(let response): + return response + case .failure(let error): + throw error + } + } + + if let getWebEntitlementsResponse { + return getWebEntitlementsResponse + } + if let redeemError { + throw redeemError + } + throw NetworkError.unknown + } + override func getIntroOfferToken( productIds: [String], appTransactionId: String, diff --git a/Tests/SuperwallKitTests/Network/NetworkTests.swift b/Tests/SuperwallKitTests/Network/NetworkTests.swift index 20edf29f1c..e78781056e 100644 --- a/Tests/SuperwallKitTests/Network/NetworkTests.swift +++ b/Tests/SuperwallKitTests/Network/NetworkTests.swift @@ -92,4 +92,32 @@ final class NetworkTests: XCTestCase { ) XCTAssertTrue(urlSession.didRequest) } + + func test_pollRedemptionResult_endpointBuildsRequest() async throws { + let dependencyContainer = DependencyContainer() + let request = PollRedemptionResultRequest( + checkoutContextId: "ctx_123", + deviceId: "device_123", + appUserId: "user_123" + ) + let endpoint = Endpoint.pollRedemptionResult(request: request) + + let urlRequest = await endpoint.makeRequest( + with: SuperwallRequestData(factory: dependencyContainer), + factory: dependencyContainer + ) + + XCTAssertEqual(urlRequest?.httpMethod, "POST") + XCTAssertTrue( + urlRequest?.url?.absoluteString.contains( + "/subscriptions-api/public/v1/checkout/session/poll-redemption-result" + ) == true + ) + + let bodyData = try XCTUnwrap(urlRequest?.httpBody) + let bodyJson = try XCTUnwrap(try JSONSerialization.jsonObject(with: bodyData) as? [String: Any]) + XCTAssertEqual(bodyJson["checkoutContextId"] as? String, "ctx_123") + XCTAssertEqual(bodyJson["deviceId"] as? String, "device_123") + XCTAssertEqual(bodyJson["appUserId"] as? String, "user_123") + } } 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 616007d934..23caf94c02 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 @@ -44,6 +44,9 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { var didPresentSafariExternal = false var didRequestReview = false var didOpenPaymentSheet = false + var stripeCheckoutStart: (checkoutContextId: String, productId: String)? + var stripeCheckoutComplete: (swCheckoutId: String, checkoutContextId: String, productId: String)? + var stripeCheckoutAbandon: (checkoutContextId: String, productId: String)? var request: PresentationRequest? @@ -89,4 +92,20 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { func openPaymentSheet(_ url: URL) { didOpenPaymentSheet = true } + + func handleStripeCheckoutStart(checkoutContextId: String, productId: String) { + stripeCheckoutStart = (checkoutContextId, productId) + } + + func handleStripeCheckoutComplete( + swCheckoutId: String, + checkoutContextId: String, + productId: String + ) { + stripeCheckoutComplete = (swCheckoutId, checkoutContextId, productId) + } + + func handleStripeCheckoutAbandon(checkoutContextId: String, productId: String) { + stripeCheckoutAbandon = (checkoutContextId, productId) + } } diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift index da577c461b..e8e9ec51ec 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift @@ -560,4 +560,222 @@ struct PaywallMessageHandlerTests { // Haptic feedback doesn't trigger delegate events, so we just verify it doesn't crash #expect(delegate.eventDidOccur == nil) } + + @Test + func decodeStripeCheckoutStart() throws { + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "event_name": "stripe_checkout_start", + "checkout_context_id": "ctx_123", + "product_identifier": "prod_123" + } + ] + } + } + """ + + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + let message = wrapped.payload.messages.first + + #expect(message == .stripeCheckoutStart(checkoutContextId: "ctx_123", productId: "prod_123")) + } + + @Test + func decodeStripeCheckoutComplete() throws { + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "event_name": "stripe_checkout_complete", + "sw_checkout_id": "sw_123", + "checkout_context_id": "ctx_123", + "product_identifier": "prod_123" + } + ] + } + } + """ + + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + let message = wrapped.payload.messages.first + + #expect(message == .stripeCheckoutComplete( + swCheckoutId: "sw_123", + checkoutContextId: "ctx_123", + productId: "prod_123" + )) + } + + @Test + func decodeStripeCheckoutFail() throws { + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "event_name": "stripe_checkout_fail", + "checkout_context_id": "ctx_123", + "product_identifier": "prod_123" + } + ] + } + } + """ + + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + let message = wrapped.payload.messages.first + + #expect(message == .stripeCheckoutFail(checkoutContextId: "ctx_123", productId: "prod_123")) + } + + @Test + func decodeStripeCheckoutAbandon() throws { + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "event_name": "stripe_checkout_abandon", + "checkout_context_id": "ctx_123", + "product_identifier": "prod_123" + } + ] + } + } + """ + + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + let message = wrapped.payload.messages.first + + #expect(message == .stripeCheckoutAbandon(checkoutContextId: "ctx_123", productId: "prod_123")) + } + + @Test + func handleStripeCheckoutStart_forwardsToDelegate() { + let dependencyContainer = DependencyContainer() + let messageHandler = PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + let webView = FakeWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + let delegate = PaywallMessageHandlerDelegateMock( + paywallInfo: .stub(), + webView: webView + ) + messageHandler.delegate = delegate + + messageHandler.handle(.stripeCheckoutStart(checkoutContextId: "ctx_123", productId: "prod_123")) + + #expect(delegate.stripeCheckoutStart?.checkoutContextId == "ctx_123") + #expect(delegate.stripeCheckoutStart?.productId == "prod_123") + } + + @Test + func handleStripeCheckoutComplete_forwardsToDelegate() { + let dependencyContainer = DependencyContainer() + let messageHandler = PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + let webView = FakeWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + let delegate = PaywallMessageHandlerDelegateMock( + paywallInfo: .stub(), + webView: webView + ) + messageHandler.delegate = delegate + + messageHandler.handle( + .stripeCheckoutComplete( + swCheckoutId: "sw_123", + checkoutContextId: "ctx_123", + productId: "prod_123" + ) + ) + + #expect(delegate.stripeCheckoutComplete?.swCheckoutId == "sw_123") + #expect(delegate.stripeCheckoutComplete?.checkoutContextId == "ctx_123") + #expect(delegate.stripeCheckoutComplete?.productId == "prod_123") + } + + @Test + func handleStripeCheckoutAbandon_forwardsToDelegate() { + let dependencyContainer = DependencyContainer() + let messageHandler = PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + let webView = FakeWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + let delegate = PaywallMessageHandlerDelegateMock( + paywallInfo: .stub(), + webView: webView + ) + messageHandler.delegate = delegate + + messageHandler.handle(.stripeCheckoutAbandon(checkoutContextId: "ctx_123", productId: "prod_123")) + + #expect(delegate.stripeCheckoutAbandon?.checkoutContextId == "ctx_123") + #expect(delegate.stripeCheckoutAbandon?.productId == "prod_123") + } + + @Test + func handleStripeCheckoutFail_isNoOp() { + let dependencyContainer = DependencyContainer() + let messageHandler = PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + let webView = FakeWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + let delegate = PaywallMessageHandlerDelegateMock( + paywallInfo: .stub(), + webView: webView + ) + messageHandler.delegate = delegate + + messageHandler.handle(.stripeCheckoutFail(checkoutContextId: "ctx_123", productId: "prod_123")) + + #expect(delegate.stripeCheckoutStart == nil) + #expect(delegate.stripeCheckoutComplete == nil) + #expect(delegate.stripeCheckoutAbandon == nil) + #expect(delegate.eventDidOccur == nil) + } } diff --git a/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift b/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift index 106f057bad..76151fc1a7 100644 --- a/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift +++ b/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift @@ -12,9 +12,18 @@ final class MockSuperwallDelegate: SuperwallDelegate { var receivedResult: RedemptionResult? var eventsReceived: [SuperwallEvent] = [] var receivedUserAttributes: [String: Any]? + var willRedeemCallCount = 0 + var willRedeemCalledAt: Date? + var didRedeemCalledAt: Date? func didRedeemLink(result: RedemptionResult) { receivedResult = result + didRedeemCalledAt = Date() + } + + func willRedeemLink() { + willRedeemCallCount += 1 + willRedeemCalledAt = Date() } func handleSuperwallEvent(withInfo eventInfo: SuperwallEventInfo) { diff --git a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift index 670c55f272..accd83f3e7 100644 --- a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift +++ b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift @@ -1144,4 +1144,426 @@ struct WebEntitlementRedeemerTests { // Both redemptions should have completed (existingCodes is not blocked) #expect(mockNetwork.redeemCallCount == 2, "Both redemptions should have made network calls since .existingCodes is not blocked") } + + @Test("Stripe checkout start persists pending context with default attempts") + func testStripeCheckoutStart_persistsPendingState() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let mockNetwork = NetworkMock( + options: dependencyContainer.makeSuperwallOptions(), + factory: dependencyContainer + ) + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + await redeemer.registerStripeCheckoutStart(contextId: "ctx_1", productId: "prod_1") + + let state = mockStorage.get(PendingStripeCheckoutPollStorage.self) + #expect(state?.checkoutContextId == "ctx_1") + #expect(state?.productId == "prod_1") + #expect(state?.remainingForegroundAttempts == 5) + } + + @Test("Stripe checkout start replaces older pending context") + func testStripeCheckoutStart_replacesPendingState() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let mockNetwork = NetworkMock( + options: dependencyContainer.makeSuperwallOptions(), + factory: dependencyContainer + ) + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + await redeemer.registerStripeCheckoutStart(contextId: "ctx_old", productId: "prod_old") + await redeemer.registerStripeCheckoutStart(contextId: "ctx_new", productId: "prod_new") + + let state = mockStorage.get(PendingStripeCheckoutPollStorage.self) + #expect(state?.checkoutContextId == "ctx_new") + #expect(state?.productId == "prod_new") + #expect(state?.remainingForegroundAttempts == 5) + } + + @Test("Stripe checkout complete polls immediately, invokes will/did callbacks, and clears pending on success") + func testStripeCheckoutComplete_success_immediatePoll() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockDelegate = MockSuperwallDelegate() + let delegateAdapter = SuperwallDelegateAdapter() + delegateAdapter.swiftDelegate = mockDelegate + superwall.delegate = mockDelegate + dependencyContainer.delegateAdapter = delegateAdapter + + let mockStorage = StorageMock(internalRedeemResponse: nil) + let options = dependencyContainer.makeSuperwallOptions() + options.paywalls.shouldShowWebPurchaseConfirmationAlert = false + let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) + + let entitlements: Set = [.stub()] + let result = RedemptionResult.success( + code: "redemption_123", + redemptionInfo: .init( + ownership: .appUser(appUserId: "appUserId"), + purchaserInfo: .init( + appUserId: "appUserId", + email: nil, + storeIdentifiers: .stripe(customerId: "cus_123", subscriptionIds: ["sub_123"]) + ), + entitlements: entitlements + ) + ) + mockNetwork.pollRedemptionResultResponses = [ + .success( + RedeemResponse( + results: [result], + customerInfo: CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: Array(entitlements) + ) + ) + ) + ] + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + await redeemer.handleStripeCheckoutComplete(contextId: "ctx_1", productId: "prod_1") + + #expect(mockNetwork.pollRedemptionResultCallCount == 1) + #expect(mockStorage.get(PendingStripeCheckoutPollStorage.self) == nil) + #expect(mockDelegate.willRedeemCallCount == 1) + #expect(mockDelegate.receivedResult?.code == "redemption_123") + + if let willAt = mockDelegate.willRedeemCalledAt, + let didAt = mockDelegate.didRedeemCalledAt { + #expect(didAt.timeIntervalSince(willAt) >= 0.19) + } else { + Issue.record("Expected will/did redeem callbacks to be invoked") + } + } + + @Test("Legacy redeem keeps callback compatibility: willRedeemLink fires before /redeem request") + func testLegacyRedeem_callbacksCompatibility() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockDelegate = MockSuperwallDelegate() + let delegateAdapter = SuperwallDelegateAdapter() + delegateAdapter.swiftDelegate = mockDelegate + superwall.delegate = mockDelegate + dependencyContainer.delegateAdapter = delegateAdapter + + let mockStorage = StorageMock(internalRedeemResponse: nil) + let options = dependencyContainer.makeSuperwallOptions() + options.paywalls.shouldShowWebPurchaseConfirmationAlert = false + let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) + + let entitlements: Set = [.stub()] + let result = RedemptionResult.success( + code: "legacy_code", + redemptionInfo: .init( + ownership: .appUser(appUserId: "appUserId"), + purchaserInfo: .init( + appUserId: "appUserId", + email: nil, + storeIdentifiers: .stripe(customerId: "cus_123", subscriptionIds: ["sub_123"]) + ), + entitlements: entitlements + ) + ) + mockNetwork.getWebEntitlementsResponse = RedeemResponse( + results: [result], + customerInfo: CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: Array(entitlements) + ) + ) + + var willRedeemCountAtRequestStart = -1 + mockNetwork.onRedeemEntitlements = { + willRedeemCountAtRequestStart = mockDelegate.willRedeemCallCount + } + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + await redeemer.redeem(.code("legacy_code")) + + #expect(willRedeemCountAtRequestStart == 1) + #expect(mockDelegate.willRedeemCallCount == 1) + #expect(mockDelegate.receivedResult?.code == "legacy_code") + } + + @Test("Stripe checkout complete retries no-redemption 3 times and keeps pending state") + func testStripeCheckoutComplete_noRedemption_retriesAndKeepsPending() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let options = dependencyContainer.makeSuperwallOptions() + options.paywalls.shouldShowWebPurchaseConfirmationAlert = false + let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) + mockNetwork.pollRedemptionResultResponses = [ + .success(RedeemResponse(results: [], customerInfo: .blank())), + .success(RedeemResponse(results: [], customerInfo: .blank())), + .success(RedeemResponse(results: [], customerInfo: .blank())), + .success(RedeemResponse(results: [], customerInfo: .blank())) + ] + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + await redeemer.handleStripeCheckoutComplete(contextId: "ctx_1", productId: "prod_1") + + #expect(mockNetwork.pollRedemptionResultCallCount == 4) + let state = mockStorage.get(PendingStripeCheckoutPollStorage.self) + #expect(state?.checkoutContextId == "ctx_1") + #expect(state?.remainingForegroundAttempts == 5) + } + + @Test("Foreground polling consumes attempts and clears pending after 5 tries") + func testStripeForegroundPolling_consumesAttempts() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let mockNetwork = NetworkMock( + options: dependencyContainer.makeSuperwallOptions(), + factory: dependencyContainer + ) + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + await redeemer.registerStripeCheckoutStart(contextId: "ctx_1", productId: "prod_1") + mockNetwork.pollRedemptionResultResponses = Array( + repeating: .failure(NetworkError.unknown), + count: 5 + ) + + await redeemer.pollPendingStripeCheckoutOnForegroundIfNeeded() + #expect(mockStorage.get(PendingStripeCheckoutPollStorage.self)?.remainingForegroundAttempts == 4) + + await redeemer.pollPendingStripeCheckoutOnForegroundIfNeeded() + await redeemer.pollPendingStripeCheckoutOnForegroundIfNeeded() + await redeemer.pollPendingStripeCheckoutOnForegroundIfNeeded() + await redeemer.pollPendingStripeCheckoutOnForegroundIfNeeded() + + #expect(mockNetwork.pollRedemptionResultCallCount == 5) + #expect(mockStorage.get(PendingStripeCheckoutPollStorage.self) == nil) + } + + @Test("Foreground Stripe recovery success uses fake callback timing and clears pending context") + func testStripeForegroundPolling_success_fakeCallbacksAndClearsPending() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockDelegate = MockSuperwallDelegate() + let delegateAdapter = SuperwallDelegateAdapter() + delegateAdapter.swiftDelegate = mockDelegate + superwall.delegate = mockDelegate + dependencyContainer.delegateAdapter = delegateAdapter + + let mockStorage = StorageMock(internalRedeemResponse: nil) + let options = dependencyContainer.makeSuperwallOptions() + options.paywalls.shouldShowWebPurchaseConfirmationAlert = false + let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) + + let entitlements: Set = [.stub()] + let result = RedemptionResult.success( + code: "redemption_foreground", + redemptionInfo: .init( + ownership: .appUser(appUserId: "appUserId"), + purchaserInfo: .init( + appUserId: "appUserId", + email: nil, + storeIdentifiers: .stripe(customerId: "cus_123", subscriptionIds: ["sub_123"]) + ), + entitlements: entitlements + ) + ) + mockNetwork.pollRedemptionResultResponses = [ + .success( + RedeemResponse( + results: [result], + customerInfo: CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: Array(entitlements) + ) + ) + ) + ] + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + await redeemer.registerStripeCheckoutStart(contextId: "ctx_fg_1", productId: "prod_fg_1") + await redeemer.pollPendingStripeCheckoutOnForegroundIfNeeded() + + #expect(mockNetwork.pollRedemptionResultCallCount == 1) + #expect(mockStorage.get(PendingStripeCheckoutPollStorage.self) == nil) + #expect(mockDelegate.willRedeemCallCount == 1) + #expect(mockDelegate.receivedResult?.code == "redemption_foreground") + + if let willAt = mockDelegate.willRedeemCalledAt, + let didAt = mockDelegate.didRedeemCalledAt { + #expect(didAt.timeIntervalSince(willAt) >= 0.19) + } else { + Issue.record("Expected will/did redeem callbacks to be invoked for foreground poll success") + } + } + + @Test("Foreground Stripe recovery no-redemption retries and consumes one attempt") + func testStripeForegroundPolling_noRedemption_retriesAndConsumesAttempt() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let options = dependencyContainer.makeSuperwallOptions() + options.paywalls.shouldShowWebPurchaseConfirmationAlert = false + let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) + mockNetwork.pollRedemptionResultResponses = [ + .success(RedeemResponse(results: [], customerInfo: .blank())), + .success(RedeemResponse(results: [], customerInfo: .blank())), + .success(RedeemResponse(results: [], customerInfo: .blank())), + .success(RedeemResponse(results: [], customerInfo: .blank())) + ] + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + await redeemer.registerStripeCheckoutStart(contextId: "ctx_fg_2", productId: "prod_fg_2") + await redeemer.pollPendingStripeCheckoutOnForegroundIfNeeded() + + #expect(mockNetwork.pollRedemptionResultCallCount == 4) + let state = mockStorage.get(PendingStripeCheckoutPollStorage.self) + #expect(state?.checkoutContextId == "ctx_fg_2") + #expect(state?.remainingForegroundAttempts == 4) + } + + @Test("Stripe checkout abandon tracks transaction_abandon and does not clear pending") + func testStripeCheckoutAbandon_tracksAndKeepsPending() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockDelegate = MockSuperwallDelegate() + let delegateAdapter = SuperwallDelegateAdapter() + delegateAdapter.swiftDelegate = mockDelegate + superwall.delegate = mockDelegate + dependencyContainer.delegateAdapter = delegateAdapter + + let mockStorage = StorageMock(internalRedeemResponse: nil) + let mockNetwork = NetworkMock( + options: dependencyContainer.makeSuperwallOptions(), + factory: dependencyContainer + ) + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + await redeemer.registerStripeCheckoutStart(contextId: "ctx_1", productId: "prod_1") + await redeemer.handleStripeCheckoutAbandon(productId: "prod_1") + + let events = mockDelegate.eventsReceived.map { $0.backingData.objcEvent } + #expect(events.contains(SuperwallEventObjc.transactionAbandon)) + #expect(mockStorage.get(PendingStripeCheckoutPollStorage.self)?.checkoutContextId == "ctx_1") + } } From 86c580271b02fdcb05ab49aafe7a33c7e2e2d670 Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Fri, 13 Feb 2026 16:50:25 +0100 Subject: [PATCH 02/11] feat: add event logging & checkout_submit handler --- .../Message Handling/PaywallMessage.swift | 11 ++++ .../PaywallMessageHandler.swift | 39 +++++++++++++- .../Web/WebEntitlementRedeemer.swift | 6 ++- .../PaywallMessageHandlerTests.swift | 53 +++++++++++++++++++ 4 files changed, 106 insertions(+), 3 deletions(-) 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 a79a3fe777..075cea9109 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 @@ -62,6 +62,7 @@ enum PaywallMessage: Decodable, Equatable { checkoutContextId: String, productId: String ) + case stripeCheckoutSubmit(checkoutContextId: String, productId: String) case stripeCheckoutFail(checkoutContextId: String, productId: String) case stripeCheckoutAbandon(checkoutContextId: String, productId: String) case requestStoreReview(ReviewType) @@ -112,6 +113,7 @@ enum PaywallMessage: Decodable, Equatable { case initiateWebCheckout = "initiate_web_checkout" case stripeCheckoutStart = "stripe_checkout_start" case stripeCheckoutComplete = "stripe_checkout_complete" + case stripeCheckoutSubmit = "stripe_checkout_submit" case stripeCheckoutFail = "stripe_checkout_fail" case stripeCheckoutAbandon = "stripe_checkout_abandon" case requestStoreReview = "request_store_review" @@ -240,6 +242,15 @@ enum PaywallMessage: Decodable, Equatable { ) return } + case .stripeCheckoutSubmit: + if let checkoutContextId = try? values.decode(String.self, forKey: .checkoutContextId), + let productId = try? values.decode(String.self, forKey: .productId) { + self = .stripeCheckoutSubmit( + checkoutContextId: checkoutContextId, + productId: productId + ) + return + } case .stripeCheckoutFail: if let checkoutContextId = try? values.decode(String.self, forKey: .checkoutContextId), let productId = try? values.decode(String.self, forKey: .productId) { 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 9b698e1a46..d3048334f5 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 @@ -195,17 +195,34 @@ final class PaywallMessageHandler: WebEventDelegate { // and error when decoding the message. break case let .stripeCheckoutStart(checkoutContextId, productId): + trackStripeCheckoutEvent( + eventName: "stripe_checkout_start", + productId: productId + ) delegate?.handleStripeCheckoutStart( checkoutContextId: checkoutContextId, productId: productId ) case let .stripeCheckoutComplete(swCheckoutId, checkoutContextId, productId): + trackStripeCheckoutEvent( + eventName: "stripe_checkout_complete", + productId: productId + ) delegate?.handleStripeCheckoutComplete( swCheckoutId: swCheckoutId, checkoutContextId: checkoutContextId, productId: productId ) - case .stripeCheckoutFail: + case let .stripeCheckoutSubmit(_, productId): + trackStripeCheckoutEvent( + eventName: "stripe_checkout_submit", + productId: productId + ) + case let .stripeCheckoutFail(_, productId): + trackStripeCheckoutEvent( + eventName: "stripe_checkout_fail", + productId: productId + ) // No-op: don't clear checkout context on failure break case let .stripeCheckoutAbandon(checkoutContextId, productId): @@ -511,6 +528,26 @@ final class PaywallMessageHandler: WebEventDelegate { delegate?.eventDidOccur(.userAttributesUpdated(attributes: attributes)) } + private func trackStripeCheckoutEvent( + eventName: String, + productId: String + ) { + let params: [String: Any] = [ + "store": "STRIPE", + "product_identifier": productId + ] + + Task { + let event = UserInitiatedPlacement.Track( + rawName: eventName, + canImplicitlyTriggerPaywall: false, + audienceFilterParams: params, + isFeatureGatable: false + ) + await Superwall.shared.track(event) + } + } + private func detectHiddenPaywallEvent( _ eventName: String, userInfo: [String: Any]? = nil diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index 0f13a4d8ef..9320f84511 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -134,7 +134,7 @@ actor WebEntitlementRedeemer { // Also check once on SDK initialization so pending Stripe checkouts can be // recovered on cold launch. Task { - if await factory.makeConfigManager() == nil { + if factory.makeConfigManager() == nil { return } await pollPendingStripeCheckoutOnForegroundIfNeeded() @@ -667,6 +667,8 @@ actor WebEntitlementRedeemer { clearPendingStripeCheckoutState() return .redeemed } catch { + // Intentional: we don't retry network failures in this trigger. + // Pending state remains persisted, so recovery continues on subsequent foreground polls. Logger.debug( logLevel: .warn, scope: .webEntitlements, @@ -689,7 +691,7 @@ actor WebEntitlementRedeemer { @objc nonisolated private func handleAppForeground() { Task { - if await factory.makeConfigManager() == nil { + if factory.makeConfigManager() == nil { return } await pollPendingStripeCheckoutOnForegroundIfNeeded() diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift index e8e9ec51ec..53581fc9cb 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift @@ -638,6 +638,30 @@ struct PaywallMessageHandlerTests { #expect(message == .stripeCheckoutFail(checkoutContextId: "ctx_123", productId: "prod_123")) } + @Test + func decodeStripeCheckoutSubmit() throws { + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "event_name": "stripe_checkout_submit", + "checkout_context_id": "ctx_123", + "product_identifier": "prod_123" + } + ] + } + } + """ + + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + let message = wrapped.payload.messages.first + + #expect(message == .stripeCheckoutSubmit(checkoutContextId: "ctx_123", productId: "prod_123")) + } + @Test func decodeStripeCheckoutAbandon() throws { let json = """ @@ -778,4 +802,33 @@ struct PaywallMessageHandlerTests { #expect(delegate.stripeCheckoutAbandon == nil) #expect(delegate.eventDidOccur == nil) } + + @Test + func handleStripeCheckoutSubmit_isNoOp() { + let dependencyContainer = DependencyContainer() + let messageHandler = PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler(), + customCallbackRegistry: dependencyContainer.customCallbackRegistry + ) + let webView = FakeWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + let delegate = PaywallMessageHandlerDelegateMock( + paywallInfo: .stub(), + webView: webView + ) + messageHandler.delegate = delegate + + messageHandler.handle(.stripeCheckoutSubmit(checkoutContextId: "ctx_123", productId: "prod_123")) + + #expect(delegate.stripeCheckoutStart == nil) + #expect(delegate.stripeCheckoutComplete == nil) + #expect(delegate.stripeCheckoutAbandon == nil) + #expect(delegate.eventDidOccur == nil) + } } From dd1eb12094035e5243eeda19b0d1e7d05f8ef32f Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Fri, 13 Feb 2026 17:07:54 +0100 Subject: [PATCH 03/11] fix: tests & bump version --- CHANGELOG.md | 7 +++++++ Sources/SuperwallKit/Misc/Constants.swift | 2 +- .../Web View/Message Handling/PaywallMessageHandler.swift | 1 - Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift | 2 +- Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift | 1 + 5 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ccca29ed2..502db1f558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## 4.13.1 + +### Enhancements + +- Adds Stripe checkout message handling for `stripe_checkout_start`, `stripe_checkout_submit`, `stripe_checkout_complete`, `stripe_checkout_fail`, and `stripe_checkout_abandon`. +- Adds SDK-side analytics tracking for Stripe checkout lifecycle events (`start`, `submit`, `complete`, `fail`) with `store` and `product_identifier` payload fields. + ## 4.13.0 ### Enhancements diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index c2cbdd36f5..5d85639455 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.13.0 +4.13.1 """ 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 d3048334f5..f140553bdc 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 @@ -224,7 +224,6 @@ final class PaywallMessageHandler: WebEventDelegate { productId: productId ) // No-op: don't clear checkout context on failure - break case let .stripeCheckoutAbandon(checkoutContextId, productId): delegate?.handleStripeCheckoutAbandon( checkoutContextId: checkoutContextId, diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index 9320f84511..c47269cdf8 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -480,7 +480,7 @@ actor WebEntitlementRedeemer { try? await Task.sleep(nanoseconds: 200_000_000) await self.delegate.didRedeemLink(result: codeResult) } - + // Schedule free trial notification if applicable if case .success(_, let redemptionInfo) = codeResult, let product = redemptionInfo.paywallInfo?.product, diff --git a/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift b/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift index 76151fc1a7..a43e87cd6e 100644 --- a/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift +++ b/Tests/SuperwallKitTests/Web/MockSuperwallDelegate.swift @@ -6,6 +6,7 @@ // import Testing +import Foundation @testable import SuperwallKit final class MockSuperwallDelegate: SuperwallDelegate { From f1684c19a8b27833d3634e7668c0cf9152adcd14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:28:05 +0100 Subject: [PATCH 04/11] Remove unused vars --- .../PaywallViewController.swift | 5 +-- .../PaywallMessageHandler.swift | 7 +--- .../Web/PendingStripeCheckoutPollState.swift | 38 +++++++++++++++++++ .../Web/WebEntitlementRedeemer.swift | 31 +-------------- 4 files changed, 42 insertions(+), 39 deletions(-) create mode 100644 Sources/SuperwallKit/Web/PendingStripeCheckoutPollState.swift diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index f7a7b5fbdf..415498f9f6 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -1153,11 +1153,9 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { } func handleStripeCheckoutComplete( - swCheckoutId: String, checkoutContextId: String, productId: String ) { - _ = swCheckoutId // Included for analytics parity from paywall events. didReceiveStripeCheckoutAbandonMessage = true loadingState = .manualLoading closeSafari() @@ -1170,8 +1168,7 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { } } - func handleStripeCheckoutAbandon(checkoutContextId: String, productId: String) { - _ = checkoutContextId // Context is persisted by start/complete and intentionally not cleared on abandon. + func handleStripeCheckoutAbandon(productId: String) { didReceiveStripeCheckoutAbandonMessage = true transactionAbandonWorkItem?.cancel() transactionAbandonWorkItem = nil 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 f140553bdc..b0a3e14367 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 @@ -25,11 +25,10 @@ protocol PaywallMessageHandlerDelegate: AnyObject { func openPaymentSheet(_ url: URL) func handleStripeCheckoutStart(checkoutContextId: String, productId: String) func handleStripeCheckoutComplete( - swCheckoutId: String, checkoutContextId: String, productId: String ) - func handleStripeCheckoutAbandon(checkoutContextId: String, productId: String) + func handleStripeCheckoutAbandon(productId: String) } @MainActor @@ -203,13 +202,12 @@ final class PaywallMessageHandler: WebEventDelegate { checkoutContextId: checkoutContextId, productId: productId ) - case let .stripeCheckoutComplete(swCheckoutId, checkoutContextId, productId): + case let .stripeCheckoutComplete(_, checkoutContextId, productId): trackStripeCheckoutEvent( eventName: "stripe_checkout_complete", productId: productId ) delegate?.handleStripeCheckoutComplete( - swCheckoutId: swCheckoutId, checkoutContextId: checkoutContextId, productId: productId ) @@ -226,7 +224,6 @@ final class PaywallMessageHandler: WebEventDelegate { // No-op: don't clear checkout context on failure case let .stripeCheckoutAbandon(checkoutContextId, productId): delegate?.handleStripeCheckoutAbandon( - checkoutContextId: checkoutContextId, productId: productId ) case .requestStoreReview(let reviewType): diff --git a/Sources/SuperwallKit/Web/PendingStripeCheckoutPollState.swift b/Sources/SuperwallKit/Web/PendingStripeCheckoutPollState.swift new file mode 100644 index 0000000000..2fbc4a1028 --- /dev/null +++ b/Sources/SuperwallKit/Web/PendingStripeCheckoutPollState.swift @@ -0,0 +1,38 @@ +// +// PendingStripeCheckoutPollState.swift +// SuperwallKit +// +// Created by Yusuf Tör on 13/02/2026. +// + +import Foundation + +struct PendingStripeCheckoutPollState: Codable, Equatable { + static let defaultForegroundAttempts = 5 + + let checkoutContextId: String + let productId: String + let remainingForegroundAttempts: Int + let updatedAt: Date + + init( + checkoutContextId: String, + productId: String, + remainingForegroundAttempts: Int = defaultForegroundAttempts, + updatedAt: Date = Date() + ) { + self.checkoutContextId = checkoutContextId + self.productId = productId + self.remainingForegroundAttempts = remainingForegroundAttempts + self.updatedAt = updatedAt + } + + func consumingForegroundAttempt() -> PendingStripeCheckoutPollState { + PendingStripeCheckoutPollState( + checkoutContextId: checkoutContextId, + productId: productId, + remainingForegroundAttempts: max(remainingForegroundAttempts - 1, 0), + updatedAt: Date() + ) + } +} diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index c47269cdf8..0e26306b8f 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -9,36 +9,6 @@ import UIKit import Foundation -struct PendingStripeCheckoutPollState: Codable, Equatable { - static let defaultForegroundAttempts = 5 - - let checkoutContextId: String - let productId: String - let remainingForegroundAttempts: Int - let updatedAt: Date - - init( - checkoutContextId: String, - productId: String, - remainingForegroundAttempts: Int = defaultForegroundAttempts, - updatedAt: Date = Date() - ) { - self.checkoutContextId = checkoutContextId - self.productId = productId - self.remainingForegroundAttempts = remainingForegroundAttempts - self.updatedAt = updatedAt - } - - func consumingForegroundAttempt() -> PendingStripeCheckoutPollState { - PendingStripeCheckoutPollState( - checkoutContextId: checkoutContextId, - productId: productId, - remainingForegroundAttempts: max(remainingForegroundAttempts - 1, 0), - updatedAt: Date() - ) - } -} - actor WebEntitlementRedeemer { private unowned let network: Network private unowned let storage: Storage @@ -681,6 +651,7 @@ actor WebEntitlementRedeemer { ], error: error ) + // Is this intentional or we only want to backoff if no code found? The network req itself backs off so maybe we don't want to have backoff here too. return .requestFailed } } From 0eb5d326069a71ec3f4467d4fedb8f0e18002750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:50:53 +0100 Subject: [PATCH 05/11] Update WebEntitlementRedeemer.swift --- Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index 0e26306b8f..01a4250c3f 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -183,12 +183,14 @@ actor WebEntitlementRedeemer { // Consume foreground attempts after each trigger completes, except when skipped // due to an existing in-flight poll. - guard outcome != .skippedInFlight else { + if outcome == .skippedInFlight { return } - guard let latestState = pendingStripeCheckoutState, - latestState.checkoutContextId == pendingState.checkoutContextId else { + guard + let latestState = pendingStripeCheckoutState, + latestState.checkoutContextId == pendingState.checkoutContextId + else { return } @@ -651,7 +653,6 @@ actor WebEntitlementRedeemer { ], error: error ) - // Is this intentional or we only want to backoff if no code found? The network req itself backs off so maybe we don't want to have backoff here too. return .requestFailed } } From 35164acf00364eac9517ddcd63dd14df635ee3cd Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Mon, 16 Feb 2026 21:27:32 +0100 Subject: [PATCH 06/11] fix: update polling logic to be status based --- .../Models/Web2App/RedeemResponse.swift | 14 +- .../PaywallViewController.swift | 60 ++- .../PaywallMessageHandler.swift | 18 +- .../Web/WebEntitlementRedeemer.swift | 279 ++++++++++--- .../PaywallMessageHandlerDelegateMock.swift | 7 + .../PaywallMessageHandlerTests.swift | 9 +- .../Web/WebEntitlementRedeemerTests.swift | 388 ++++++++++++++++-- 7 files changed, 686 insertions(+), 89 deletions(-) diff --git a/Sources/SuperwallKit/Models/Web2App/RedeemResponse.swift b/Sources/SuperwallKit/Models/Web2App/RedeemResponse.swift index 5d1f0b39c1..34acf51f18 100644 --- a/Sources/SuperwallKit/Models/Web2App/RedeemResponse.swift +++ b/Sources/SuperwallKit/Models/Web2App/RedeemResponse.swift @@ -10,12 +10,20 @@ import Foundation /// An object return from the server acting as a source of truth for the redeemed codes /// and web entitlements. struct RedeemResponse: Codable { + enum PollRedemptionStatus: String, Codable { + case pending + case failed + case complete + } + var results: [RedemptionResult] var customerInfo: CustomerInfo + var status: PollRedemptionStatus? private enum CodingKeys: String, CodingKey { case results = "codes" case customerInfo + case status } var allCodes: Set { @@ -26,22 +34,26 @@ struct RedeemResponse: Codable { init( results: [RedemptionResult], - customerInfo: CustomerInfo + customerInfo: CustomerInfo, + status: PollRedemptionStatus? = nil ) { self.results = results self.customerInfo = customerInfo + self.status = status } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.results = try container.decode([RedemptionResult].self, forKey: .results) self.customerInfo = try container.decodeIfPresent(CustomerInfo.self, forKey: .customerInfo) ?? .blank() + self.status = try container.decodeIfPresent(PollRedemptionStatus.self, forKey: .status) } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(results, forKey: .results) try container.encode(customerInfo, forKey: .customerInfo) + try container.encodeIfPresent(status, forKey: .status) } } diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index f7a7b5fbdf..a6b9c7e385 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -315,6 +315,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { } await storage.trackPaywallOpen() await webView.messageHandler.handle(.paywallOpen) + await maybeRecoverPendingStripeCheckoutOnPaywallOpen() let demandScore = await deviceHelper.enrichment?.device["demandScore"].int let demandTier = await deviceHelper.enrichment?.device["demandTier"].string @@ -327,6 +328,30 @@ public class PaywallViewController: UIViewController, LoadingDelegate { await Superwall.shared.track(paywallOpen) } + nonisolated private func maybeRecoverPendingStripeCheckoutOnPaywallOpen() async { + let shouldShowLoading = await webEntitlementRedeemer.shouldShowStripeRecoveryLoadingOnPaywallOpen() + guard shouldShowLoading else { + return + } + + await MainActor.run { + self.loadingState = .manualLoading + } + + // If another poll is already in-flight (e.g. cold-launch init task), + // wait for it to finish rather than returning early. This ensures + // WE always clean up the spinner we set above. + let redeemed = await webEntitlementRedeemer.pollOrWaitForActiveStripePoll() + + if !redeemed { + await MainActor.run { + if self.loadingState == .manualLoading { + self.loadingState = .ready + } + } + } + } + nonisolated private func trackClose() async { await MainActor.run { lastOpen = nil @@ -440,6 +465,28 @@ public class PaywallViewController: UIViewController, LoadingDelegate { // MARK: - State Handling + /// Reveals the web view content behind the spinner overlay when the web + /// view finishes loading while a manual spinner (e.g. Stripe recovery) is + /// active. Animates the shimmer away and fades in the web view, but keeps + /// the spinner visible so the user knows a background operation is ongoing. + func revealWebViewBehindSpinner() { + guard shimmerView != nil else { return } + showRefreshButtonAfterTimeout(false) + UIView.animate( + withDuration: 0.6, + delay: 0.25, + animations: { + self.shimmerView?.alpha = 0.0 + self.webView.alpha = 1.0 + self.webView.transform = .identity + }, + completion: { [weak self] _ in + self?.shimmerView?.removeFromSuperview() + self?.shimmerView = nil + } + ) + } + /// Hides or displays the paywall spinner. /// /// - Parameter isHidden: A `Bool` indicating whether to show or hide the spinner. @@ -472,11 +519,12 @@ public class PaywallViewController: UIViewController, LoadingDelegate { case .ready: let translation = CGAffineTransform.identity.translatedBy(x: 0, y: 10) let spinnerDidShow = oldValue == .loadingPurchase || oldValue == .manualLoading - webView.transform = spinnerDidShow ? .identity : translation + let shimmerStillVisible = shimmerView != nil + webView.transform = spinnerDidShow && !shimmerStillVisible ? .identity : translation showRefreshButtonAfterTimeout(false) hideLoadingView() - if !spinnerDidShow { + if !spinnerDidShow || shimmerStillVisible { UIView.animate( withDuration: 0.6, delay: 0.25, @@ -1143,9 +1191,11 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { #endif } - func handleStripeCheckoutStart(checkoutContextId: String, productId: String) { + func handleStripeCheckoutStart(checkoutContextId _: String, productId _: String) {} + + func handleStripeCheckoutSubmit(checkoutContextId: String, productId: String) { Task { - await webEntitlementRedeemer.registerStripeCheckoutStart( + await webEntitlementRedeemer.registerStripeCheckoutSubmit( contextId: checkoutContextId, productId: productId ) @@ -1171,7 +1221,7 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { } func handleStripeCheckoutAbandon(checkoutContextId: String, productId: String) { - _ = checkoutContextId // Context is persisted by start/complete and intentionally not cleared on abandon. + _ = checkoutContextId // Context is persisted by submit/complete and intentionally not cleared on abandon. didReceiveStripeCheckoutAbandonMessage = true transactionAbandonWorkItem?.cancel() transactionAbandonWorkItem = nil 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 f140553bdc..4289ed75d3 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 @@ -24,12 +24,14 @@ protocol PaywallMessageHandlerDelegate: AnyObject { func requestReview(type: ReviewType) func openPaymentSheet(_ url: URL) func handleStripeCheckoutStart(checkoutContextId: String, productId: String) + func handleStripeCheckoutSubmit(checkoutContextId: String, productId: String) func handleStripeCheckoutComplete( swCheckoutId: String, checkoutContextId: String, productId: String ) func handleStripeCheckoutAbandon(checkoutContextId: String, productId: String) + func revealWebViewBehindSpinner() } @MainActor @@ -213,11 +215,15 @@ final class PaywallMessageHandler: WebEventDelegate { checkoutContextId: checkoutContextId, productId: productId ) - case let .stripeCheckoutSubmit(_, productId): + case let .stripeCheckoutSubmit(checkoutContextId, productId): trackStripeCheckoutEvent( eventName: "stripe_checkout_submit", productId: productId ) + delegate?.handleStripeCheckoutSubmit( + checkoutContextId: checkoutContextId, + productId: productId + ) case let .stripeCheckoutFail(_, productId): trackStripeCheckoutEvent( eventName: "stripe_checkout_fail", @@ -400,7 +406,15 @@ final class PaywallMessageHandler: WebEventDelegate { let delay = self?.delegate?.paywall.presentation.delay ?? 0 DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(delay)) { - self?.delegate?.loadingState = .ready + guard let delegate = self?.delegate else { return } + if delegate.loadingState == .manualLoading { + // Web content loaded while spinner is showing (e.g. Stripe + // recovery poll). Reveal the web view behind the spinner + // but keep the spinner visible until the poll finishes. + delegate.revealWebViewBehindSpinner() + } else { + delegate.loadingState = .ready + } } } diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index c47269cdf8..78f258f90d 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -34,7 +34,7 @@ struct PendingStripeCheckoutPollState: Codable, Equatable { checkoutContextId: checkoutContextId, productId: productId, remainingForegroundAttempts: max(remainingForegroundAttempts - 1, 0), - updatedAt: Date() + updatedAt: updatedAt ) } } @@ -47,8 +47,11 @@ actor WebEntitlementRedeemer { private unowned let purchaseController: PurchaseController private unowned let receiptManager: ReceiptManager private unowned let factory: Factory + private let stripePendingPollIntervalNs: UInt64 + private let stripePendingPollTimeoutNs: UInt64 private var isProcessing = false private var activeStripePollContextId: String? + private var awaitingCheckoutComplete = false private var superwall: Superwall? typealias Factory = WebEntitlementFactory & OptionsFactory @@ -59,11 +62,13 @@ actor WebEntitlementRedeemer { private enum StripePollTrigger: String { case checkoutComplete = "checkout_complete" + case paywallOpen = "paywall_open" case foreground = "foreground" } private enum StripePollOutcome { case redeemed + case checkoutFailed case noRedemptionFound case requestFailed case skippedInFlight @@ -112,6 +117,8 @@ actor WebEntitlementRedeemer { purchaseController: PurchaseController, receiptManager: ReceiptManager, factory: Factory, + stripePendingPollIntervalNs: UInt64 = 1_500_000_000, + stripePendingPollTimeoutNs: UInt64 = 60_000_000_000, superwall: Superwall? = nil ) { self.network = network @@ -122,6 +129,8 @@ actor WebEntitlementRedeemer { self.factory = factory self.superwall = superwall self.receiptManager = receiptManager + self.stripePendingPollIntervalNs = stripePendingPollIntervalNs + self.stripePendingPollTimeoutNs = stripePendingPollTimeoutNs // Observe when the app enters the foreground NotificationCenter.default.addObserver( @@ -134,17 +143,18 @@ actor WebEntitlementRedeemer { // Also check once on SDK initialization so pending Stripe checkouts can be // recovered on cold launch. Task { - if factory.makeConfigManager() == nil { + guard factory.makeConfigManager() != nil else { return } await pollPendingStripeCheckoutOnForegroundIfNeeded() } } - func registerStripeCheckoutStart( + func registerStripeCheckoutSubmit( contextId: String, productId: String ) { + awaitingCheckoutComplete = true savePendingStripeCheckoutState( .init( checkoutContextId: contextId, @@ -153,16 +163,94 @@ actor WebEntitlementRedeemer { ) } + func hasActiveStripePoll() -> Bool { + activeStripePollContextId != nil + } + + func shouldShowStripeRecoveryLoadingOnPaywallOpen() -> Bool { + guard let state = pendingStripeCheckoutState else { + return false + } + guard state.remainingForegroundAttempts > 0 else { + clearPendingStripeCheckoutState() + return false + } + guard !hasStripePendingTimedOut(state) else { + clearPendingStripeCheckoutState() + return false + } + return true + } + + /// Either starts a new poll or waits for an existing in-flight poll to + /// finish. Returns `true` if the checkout was redeemed. + func pollOrWaitForActiveStripePoll() async -> Bool { + guard let pendingState = pendingStripeCheckoutState else { + return false + } + + // If there's already an active poll (e.g. from cold-launch init task), + // wait for it to finish rather than skipping. + if activeStripePollContextId != nil { + let waitStart = DispatchTime.now().uptimeNanoseconds + while activeStripePollContextId != nil { + if Task.isCancelled { + return false + } + let elapsed = DispatchTime.now().uptimeNanoseconds - waitStart + if elapsed >= stripePendingPollTimeoutNs { + return false + } + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + } + return false + } + + // No active poll — start our own. + return await pollPendingStripeCheckoutOnPaywallOpenIfNeeded() + } + + func pollPendingStripeCheckoutOnPaywallOpenIfNeeded() async -> Bool { + guard let pendingState = pendingStripeCheckoutState else { + return false + } + guard pendingState.remainingForegroundAttempts > 0 else { + clearPendingStripeCheckoutState() + return false + } + + let outcome = await pollStripeRedemptionResult( + contextId: pendingState.checkoutContextId, + productId: pendingState.productId, + trigger: .paywallOpen + ) + + return outcome == .redeemed + } + func handleStripeCheckoutComplete( contextId: String, productId: String ) async { - savePendingStripeCheckoutState( - .init( - checkoutContextId: contextId, - productId: productId + awaitingCheckoutComplete = false + + if let existingState = pendingStripeCheckoutState, + existingState.checkoutContextId == contextId { + savePendingStripeCheckoutState( + .init( + checkoutContextId: contextId, + productId: productId, + remainingForegroundAttempts: existingState.remainingForegroundAttempts + ) ) - ) + } else { + savePendingStripeCheckoutState( + .init( + checkoutContextId: contextId, + productId: productId + ) + ) + } let outcome = await pollStripeRedemptionResult( contextId: contextId, @@ -170,7 +258,9 @@ actor WebEntitlementRedeemer { trigger: .checkoutComplete ) - if outcome != .redeemed { + // Don't hide spinner if another poll is in-flight — it will handle the + // loading state when it finishes. + if outcome != .redeemed, outcome != .skippedInFlight { let superwall = self.superwall ?? Superwall.shared await MainActor.run { superwall.paywallViewController?.loadingState = .ready @@ -179,6 +269,7 @@ actor WebEntitlementRedeemer { } func handleStripeCheckoutAbandon(productId: String) async { + awaitingCheckoutComplete = false let superwall = superwall ?? Superwall.shared let product = StoreProduct.blank(productIdentifier: productId) let paywallInfo = await MainActor.run { superwall.paywallViewController?.info ?? .empty() } @@ -513,8 +604,7 @@ actor WebEntitlementRedeemer { } } - if showConfirmation, - let paywallVc = superwall.paywallViewController { + if showConfirmation { let title = LocalizationLogic.localizedBundle().localizedString( forKey: "purchase_success_title", value: nil, @@ -531,21 +621,62 @@ actor WebEntitlementRedeemer { table: nil ) - await paywallVc.presentAlert( - title: title, - message: message, - closeActionTitle: closeActionTitle, - onClose: { - Task { - await afterRedeem() + if let paywallVc = await MainActor.run(body: { superwall.paywallViewController }) { + await paywallVc.presentAlert( + title: title, + message: message, + closeActionTitle: closeActionTitle, + onClose: { + Task { [weak self] in + await afterRedeem() + await self?.clearPendingStripeCheckoutState() + } } - } - ) + ) + } else { + await presentAlertOnTopViewController( + title: title, + message: message, + closeActionTitle: closeActionTitle, + onClose: { + Task { [weak self] in + await afterRedeem() + await self?.clearPendingStripeCheckoutState() + } + } + ) + } } else { await afterRedeem() + clearPendingStripeCheckoutState() } } + @MainActor + private func presentAlertOnTopViewController( + title: String, + message: String, + closeActionTitle: String, + onClose: (() -> Void)? + ) { + guard let topVc = UIViewController.topMostViewController else { + onClose?() + return + } + guard topVc.presentedViewController == nil else { + onClose?() + return + } + let alertController = AlertControllerFactory.make( + title: title, + message: message, + closeActionTitle: closeActionTitle, + onClose: onClose, + sourceView: topVc.view + ) + topVc.present(alertController, animated: true) + } + private func handleRedemptionFailure( error: Error, type: RedeemType, @@ -643,60 +774,108 @@ actor WebEntitlementRedeemer { } let request = createPollRedemptionRequest(contextId: contextId) - let retryBackoffsNs: [UInt64] = [1_000_000_000, 2_000_000_000, 4_000_000_000] + let startedAt = DispatchTime.now().uptimeNanoseconds + + while !Task.isCancelled { + if let state = pendingStripeCheckoutState, + state.checkoutContextId == contextId, + hasStripePendingTimedOut(state) { + clearPendingStripeCheckoutState() + return .noRedemptionFound + } - for attempt in 0...retryBackoffsNs.count { do { let response = try await network.pollRedemptionResult(request: request) - guard let code = response.results.first?.code else { - if attempt < retryBackoffsNs.count { - try? await Task.sleep(nanoseconds: retryBackoffsNs[attempt]) - continue + if let code = response.results.first?.code { + let superwall = superwall ?? Superwall.shared + await handleRedemptionSuccess( + response: response, + type: .code(code), + superwall: superwall, + callbackMode: .pollFakeCompatibility + ) + return .redeemed + } + + switch response.status { + case .failed: + clearPendingStripeCheckoutState() + return .checkoutFailed + case .pending: + let elapsed = DispatchTime.now().uptimeNanoseconds - startedAt + if elapsed >= stripePendingPollTimeoutNs { + return .noRedemptionFound } + try? await Task.sleep(nanoseconds: stripePendingPollIntervalNs) + continue + case .complete: + clearPendingStripeCheckoutState() + return .noRedemptionFound + case .none: return .noRedemptionFound } - - let superwall = superwall ?? Superwall.shared - await handleRedemptionSuccess( - response: response, - type: .code(code), - superwall: superwall, - callbackMode: .pollFakeCompatibility - ) - clearPendingStripeCheckoutState() - return .redeemed } catch { - // Intentional: we don't retry network failures in this trigger. - // Pending state remains persisted, so recovery continues on subsequent foreground polls. Logger.debug( logLevel: .warn, scope: .webEntitlements, - message: "Stripe poll-redemption-result failed", - info: [ - "checkout_context_id": contextId, - "product_id": productId, - "trigger": trigger.rawValue, - "attempt": attempt + 1 - ], + message: "Stripe poll-redemption-result request failed", error: error ) return .requestFailed } } - return .noRedemptionFound } @objc nonisolated private func handleAppForeground() { Task { - if factory.makeConfigManager() == nil { - return - } - await pollPendingStripeCheckoutOnForegroundIfNeeded() + await self.handleForegroundPolling() + } + } + + private func handleForegroundPolling() async { + if factory.makeConfigManager() == nil { + return + } + let superwall = superwall ?? Superwall.shared + let hasVisiblePaywall = await MainActor.run { superwall.paywallViewController != nil } + + // If the checkout sheet is still open inside the paywall (checkout_submit + // fired but checkout_complete hasn't yet), skip the foreground stripe poll. + // checkout_complete will show the spinner and start polling when the sheet + // closes. + if hasVisiblePaywall, awaitingCheckoutComplete { await pollWebEntitlements() + return + } + + let shouldShowLoading = hasVisiblePaywall && shouldShowStripeRecoveryLoadingOnPaywallOpen() + + if shouldShowLoading { + await MainActor.run { + superwall.paywallViewController?.loadingState = .manualLoading + } + } + + await pollPendingStripeCheckoutOnForegroundIfNeeded() + + if shouldShowLoading { + await MainActor.run { + if superwall.paywallViewController?.loadingState == .manualLoading { + superwall.paywallViewController?.loadingState = .ready + } + } } + + await pollWebEntitlements() + } + + private func hasStripePendingTimedOut(_ state: PendingStripeCheckoutPollState) -> Bool { + let elapsed = Date().timeIntervalSince(state.updatedAt) + let timeoutSeconds = TimeInterval(stripePendingPollTimeoutNs) / 1_000_000_000 + return elapsed >= timeoutSeconds } // swiftlint:disable:next cyclomatic_complexity 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 23caf94c02..0d3defd3ff 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 @@ -45,6 +45,7 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { var didRequestReview = false var didOpenPaymentSheet = false var stripeCheckoutStart: (checkoutContextId: String, productId: String)? + var stripeCheckoutSubmit: (checkoutContextId: String, productId: String)? var stripeCheckoutComplete: (swCheckoutId: String, checkoutContextId: String, productId: String)? var stripeCheckoutAbandon: (checkoutContextId: String, productId: String)? @@ -97,6 +98,10 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { stripeCheckoutStart = (checkoutContextId, productId) } + func handleStripeCheckoutSubmit(checkoutContextId: String, productId: String) { + stripeCheckoutSubmit = (checkoutContextId, productId) + } + func handleStripeCheckoutComplete( swCheckoutId: String, checkoutContextId: String, @@ -108,4 +113,6 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { func handleStripeCheckoutAbandon(checkoutContextId: String, productId: String) { stripeCheckoutAbandon = (checkoutContextId, productId) } + + func revealWebViewBehindSpinner() {} } diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift index 53581fc9cb..70718c459f 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift @@ -798,13 +798,14 @@ struct PaywallMessageHandlerTests { messageHandler.handle(.stripeCheckoutFail(checkoutContextId: "ctx_123", productId: "prod_123")) #expect(delegate.stripeCheckoutStart == nil) + #expect(delegate.stripeCheckoutSubmit == nil) #expect(delegate.stripeCheckoutComplete == nil) #expect(delegate.stripeCheckoutAbandon == nil) #expect(delegate.eventDidOccur == nil) } @Test - func handleStripeCheckoutSubmit_isNoOp() { + func handleStripeCheckoutSubmit_forwardsToDelegate() { let dependencyContainer = DependencyContainer() let messageHandler = PaywallMessageHandler( receiptManager: dependencyContainer.receiptManager, @@ -826,9 +827,7 @@ struct PaywallMessageHandlerTests { messageHandler.handle(.stripeCheckoutSubmit(checkoutContextId: "ctx_123", productId: "prod_123")) - #expect(delegate.stripeCheckoutStart == nil) - #expect(delegate.stripeCheckoutComplete == nil) - #expect(delegate.stripeCheckoutAbandon == nil) - #expect(delegate.eventDidOccur == nil) + #expect(delegate.stripeCheckoutSubmit?.checkoutContextId == "ctx_123") + #expect(delegate.stripeCheckoutSubmit?.productId == "prod_123") } } diff --git a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift index 767be4f263..b0c497342b 100644 --- a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift +++ b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift @@ -1503,8 +1503,8 @@ struct WebEntitlementRedeemerTests { #expect(mockNetwork.redeemCallCount == 2, "Both redemptions should have made network calls since .existingCodes is not blocked") } - @Test("Stripe checkout start persists pending context with default attempts") - func testStripeCheckoutStart_persistsPendingState() async { + @Test("Stripe checkout submit persists pending context with default attempts") + func testStripeCheckoutSubmit_persistsPendingState() async { guard #available(iOS 14.0, *) else { return } @@ -1526,7 +1526,7 @@ struct WebEntitlementRedeemerTests { superwall: superwall ) - await redeemer.registerStripeCheckoutStart(contextId: "ctx_1", productId: "prod_1") + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_1", productId: "prod_1") let state = mockStorage.get(PendingStripeCheckoutPollStorage.self) #expect(state?.checkoutContextId == "ctx_1") @@ -1534,8 +1534,8 @@ struct WebEntitlementRedeemerTests { #expect(state?.remainingForegroundAttempts == 5) } - @Test("Stripe checkout start replaces older pending context") - func testStripeCheckoutStart_replacesPendingState() async { + @Test("Stripe checkout submit replaces older pending context") + func testStripeCheckoutSubmit_replacesPendingState() async { guard #available(iOS 14.0, *) else { return } @@ -1557,8 +1557,8 @@ struct WebEntitlementRedeemerTests { superwall: superwall ) - await redeemer.registerStripeCheckoutStart(contextId: "ctx_old", productId: "prod_old") - await redeemer.registerStripeCheckoutStart(contextId: "ctx_new", productId: "prod_new") + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_old", productId: "prod_old") + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_new", productId: "prod_new") let state = mockStorage.get(PendingStripeCheckoutPollStorage.self) #expect(state?.checkoutContextId == "ctx_new") @@ -1566,6 +1566,46 @@ struct WebEntitlementRedeemerTests { #expect(state?.remainingForegroundAttempts == 5) } + @Test("Paywall-open Stripe recovery loading shows only for non-expired pending context") + func testStripePaywallOpenLoading_guardedByTimeout() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let mockNetwork = NetworkMock( + options: dependencyContainer.makeSuperwallOptions(), + factory: dependencyContainer + ) + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + stripePendingPollIntervalNs: 1_000_000, + stripePendingPollTimeoutNs: 5_000_000, + superwall: superwall + ) + + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_live", productId: "prod_live") + #expect(await redeemer.shouldShowStripeRecoveryLoadingOnPaywallOpen() == true) + + mockStorage.save( + PendingStripeCheckoutPollState( + checkoutContextId: "ctx_expired", + productId: "prod_expired", + updatedAt: Date(timeIntervalSinceNow: -10) + ), + forType: PendingStripeCheckoutPollStorage.self + ) + #expect(await redeemer.shouldShowStripeRecoveryLoadingOnPaywallOpen() == false) + #expect(mockStorage.get(PendingStripeCheckoutPollStorage.self) == nil) + } + @Test("Stripe checkout complete polls immediately, invokes will/did callbacks, and clears pending on success") func testStripeCheckoutComplete_success_immediatePoll() async { guard #available(iOS 14.0, *) else { @@ -1699,7 +1739,7 @@ struct WebEntitlementRedeemerTests { #expect(mockDelegate.receivedResult?.code == "legacy_code") } - @Test("Stripe checkout complete retries no-redemption 3 times and keeps pending state") + @Test("Stripe checkout complete retries no-redemption 5 times and keeps pending state") func testStripeCheckoutComplete_noRedemption_retriesAndKeepsPending() async { guard #available(iOS 14.0, *) else { return @@ -1710,12 +1750,6 @@ struct WebEntitlementRedeemerTests { let options = dependencyContainer.makeSuperwallOptions() options.paywalls.shouldShowWebPurchaseConfirmationAlert = false let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) - mockNetwork.pollRedemptionResultResponses = [ - .success(RedeemResponse(results: [], customerInfo: .blank())), - .success(RedeemResponse(results: [], customerInfo: .blank())), - .success(RedeemResponse(results: [], customerInfo: .blank())), - .success(RedeemResponse(results: [], customerInfo: .blank())) - ] let redeemer = WebEntitlementRedeemer( network: mockNetwork, @@ -1725,12 +1759,25 @@ struct WebEntitlementRedeemerTests { purchaseController: MockPurchaseController(), receiptManager: dependencyContainer.receiptManager, factory: dependencyContainer, + stripePendingPollIntervalNs: 1_000_000, + stripePendingPollTimeoutNs: 5_000_000_000, superwall: superwall ) + // Let the init task settle, then reset counters + try? await Task.sleep(nanoseconds: 50_000_000) + mockNetwork.pollRedemptionResultCallCount = 0 + // Provide exactly 6 pending responses; the 7th call will throw + // NetworkError.unknown, exiting the loop via .requestFailed. + mockNetwork.pollRedemptionResultResponses = Array( + repeating: .success(RedeemResponse(results: [], customerInfo: .blank(), status: .pending)), + count: 6 + ) + await redeemer.handleStripeCheckoutComplete(contextId: "ctx_1", productId: "prod_1") - #expect(mockNetwork.pollRedemptionResultCallCount == 4) + // 6 pending + 1 error = 7 total calls before .requestFailed exits the loop + #expect(mockNetwork.pollRedemptionResultCallCount == 7) let state = mockStorage.get(PendingStripeCheckoutPollStorage.self) #expect(state?.checkoutContextId == "ctx_1") #expect(state?.remainingForegroundAttempts == 5) @@ -1760,7 +1807,11 @@ struct WebEntitlementRedeemerTests { superwall: superwall ) - await redeemer.registerStripeCheckoutStart(contextId: "ctx_1", productId: "prod_1") + // Let the init task settle, then reset counters + try? await Task.sleep(nanoseconds: 50_000_000) + mockNetwork.pollRedemptionResultCallCount = 0 + + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_1", productId: "prod_1") mockNetwork.pollRedemptionResultResponses = Array( repeating: .failure(NetworkError.unknown), count: 5 @@ -1833,7 +1884,7 @@ struct WebEntitlementRedeemerTests { superwall: superwall ) - await redeemer.registerStripeCheckoutStart(contextId: "ctx_fg_1", productId: "prod_fg_1") + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_fg_1", productId: "prod_fg_1") await redeemer.pollPendingStripeCheckoutOnForegroundIfNeeded() #expect(mockNetwork.pollRedemptionResultCallCount == 1) @@ -1860,12 +1911,6 @@ struct WebEntitlementRedeemerTests { let options = dependencyContainer.makeSuperwallOptions() options.paywalls.shouldShowWebPurchaseConfirmationAlert = false let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) - mockNetwork.pollRedemptionResultResponses = [ - .success(RedeemResponse(results: [], customerInfo: .blank())), - .success(RedeemResponse(results: [], customerInfo: .blank())), - .success(RedeemResponse(results: [], customerInfo: .blank())), - .success(RedeemResponse(results: [], customerInfo: .blank())) - ] let redeemer = WebEntitlementRedeemer( network: mockNetwork, @@ -1875,18 +1920,129 @@ struct WebEntitlementRedeemerTests { purchaseController: MockPurchaseController(), receiptManager: dependencyContainer.receiptManager, factory: dependencyContainer, + stripePendingPollIntervalNs: 1_000_000, + stripePendingPollTimeoutNs: 5_000_000_000, superwall: superwall ) - await redeemer.registerStripeCheckoutStart(contextId: "ctx_fg_2", productId: "prod_fg_2") + // Let the init task settle, then reset counters + try? await Task.sleep(nanoseconds: 50_000_000) + mockNetwork.pollRedemptionResultCallCount = 0 + // Provide exactly 6 pending responses; the 7th call will throw + // NetworkError.unknown, exiting the loop via .requestFailed. + mockNetwork.pollRedemptionResultResponses = Array( + repeating: .success(RedeemResponse(results: [], customerInfo: .blank(), status: .pending)), + count: 6 + ) + + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_fg_2", productId: "prod_fg_2") await redeemer.pollPendingStripeCheckoutOnForegroundIfNeeded() - #expect(mockNetwork.pollRedemptionResultCallCount == 4) + // 6 pending + 1 error = 7 total calls before .requestFailed exits the loop + #expect(mockNetwork.pollRedemptionResultCallCount == 7) let state = mockStorage.get(PendingStripeCheckoutPollStorage.self) #expect(state?.checkoutContextId == "ctx_fg_2") #expect(state?.remainingForegroundAttempts == 4) } + @Test("Stripe checkout complete failed status clears pending state") + func testStripeCheckoutComplete_failedStatus_clearsPendingState() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockDelegate = MockSuperwallDelegate() + let delegateAdapter = SuperwallDelegateAdapter() + delegateAdapter.swiftDelegate = mockDelegate + superwall.delegate = mockDelegate + dependencyContainer.delegateAdapter = delegateAdapter + + let mockStorage = StorageMock(internalRedeemResponse: nil) + let mockNetwork = NetworkMock(options: dependencyContainer.makeSuperwallOptions(), factory: dependencyContainer) + mockNetwork.pollRedemptionResultResponses = [ + .success(RedeemResponse(results: [], customerInfo: .blank(), status: .failed)) + ] + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + await redeemer.handleStripeCheckoutComplete(contextId: "ctx_failed", productId: "prod_failed") + + #expect(mockNetwork.pollRedemptionResultCallCount == 1) + #expect(mockStorage.get(PendingStripeCheckoutPollStorage.self) == nil) + #expect(mockDelegate.willRedeemCallCount == 0) + } + + @Test("Foreground Stripe recovery failed status clears pending state") + func testStripeForegroundPolling_failedStatus_clearsPendingState() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let mockNetwork = NetworkMock(options: dependencyContainer.makeSuperwallOptions(), factory: dependencyContainer) + mockNetwork.pollRedemptionResultResponses = [ + .success(RedeemResponse(results: [], customerInfo: .blank(), status: .failed)) + ] + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_failed_fg", productId: "prod_failed_fg") + await redeemer.pollPendingStripeCheckoutOnForegroundIfNeeded() + + #expect(mockNetwork.pollRedemptionResultCallCount == 1) + #expect(mockStorage.get(PendingStripeCheckoutPollStorage.self) == nil) + } + + @Test("Stripe checkout complete status without codes clears pending state") + func testStripeCheckoutComplete_completeStatusWithoutCodes_clearsPendingState() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let mockNetwork = NetworkMock(options: dependencyContainer.makeSuperwallOptions(), factory: dependencyContainer) + mockNetwork.pollRedemptionResultResponses = [ + .success(RedeemResponse(results: [], customerInfo: .blank(), status: .complete)) + ] + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + await redeemer.handleStripeCheckoutComplete(contextId: "ctx_complete_no_code", productId: "prod_complete_no_code") + + #expect(mockNetwork.pollRedemptionResultCallCount == 1) + #expect(mockStorage.get(PendingStripeCheckoutPollStorage.self) == nil) + } + @Test("Stripe checkout abandon tracks transaction_abandon and does not clear pending") func testStripeCheckoutAbandon_tracksAndKeepsPending() async { guard #available(iOS 14.0, *) else { @@ -1917,11 +2073,191 @@ struct WebEntitlementRedeemerTests { superwall: superwall ) - await redeemer.registerStripeCheckoutStart(contextId: "ctx_1", productId: "prod_1") + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_1", productId: "prod_1") await redeemer.handleStripeCheckoutAbandon(productId: "prod_1") let events = mockDelegate.eventsReceived.map { $0.backingData.objcEvent } #expect(events.contains(SuperwallEventObjc.transactionAbandon)) #expect(mockStorage.get(PendingStripeCheckoutPollStorage.self)?.checkoutContextId == "ctx_1") } + + @Test("pollOrWaitForActiveStripePoll returns false when no pending state") + func testPollOrWait_noPendingState_returnsFalse() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let mockNetwork = NetworkMock( + options: dependencyContainer.makeSuperwallOptions(), + factory: dependencyContainer + ) + + // Clear any persisted pending state from previous tests + mockStorage.delete(PendingStripeCheckoutPollStorage.self) + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + // Let the init task settle, then reset counters + try? await Task.sleep(nanoseconds: 50_000_000) + mockNetwork.pollRedemptionResultCallCount = 0 + + let result = await redeemer.pollOrWaitForActiveStripePoll() + #expect(result == false) + #expect(mockNetwork.pollRedemptionResultCallCount == 0) + } + + @Test("pollOrWaitForActiveStripePoll starts own poll when no active poll") + func testPollOrWait_noActivePoll_startsOwnPoll() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let options = dependencyContainer.makeSuperwallOptions() + options.paywalls.shouldShowWebPurchaseConfirmationAlert = false + let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) + mockNetwork.pollRedemptionResultResponses = [ + .success(RedeemResponse(results: [], customerInfo: .blank(), status: .failed)) + ] + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + await redeemer.registerStripeCheckoutSubmit(contextId: "ctx_poll", productId: "prod_poll") + let result = await redeemer.pollOrWaitForActiveStripePoll() + + #expect(result == false) + #expect(mockNetwork.pollRedemptionResultCallCount == 1) + #expect(mockStorage.get(PendingStripeCheckoutPollStorage.self) == nil) + } + + @Test("Stripe checkout complete preserves existing foreground attempts for same context") + func testStripeCheckoutComplete_preservesExistingAttempts() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let options = dependencyContainer.makeSuperwallOptions() + options.paywalls.shouldShowWebPurchaseConfirmationAlert = false + let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) + + let entitlements: Set = [.stub()] + let result = RedemptionResult.success( + code: "code_preserve", + redemptionInfo: .init( + ownership: .appUser(appUserId: "appUserId"), + purchaserInfo: .init( + appUserId: "appUserId", + email: nil, + storeIdentifiers: .stripe(customerId: "cus_123", subscriptionIds: ["sub_123"]) + ), + entitlements: entitlements + ) + ) + mockNetwork.pollRedemptionResultResponses = [ + .success( + RedeemResponse( + results: [result], + customerInfo: CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: Array(entitlements) + ) + ) + ) + ] + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + // Simulate: checkout_submit saved state, then a foreground poll consumed one attempt + mockStorage.save( + PendingStripeCheckoutPollState( + checkoutContextId: "ctx_preserve", + productId: "prod_preserve", + remainingForegroundAttempts: 3 + ), + forType: PendingStripeCheckoutPollStorage.self + ) + + // checkout_complete with the same context should preserve the 3 remaining attempts + await redeemer.handleStripeCheckoutComplete(contextId: "ctx_preserve", productId: "prod_preserve") + + // Redemption succeeded, so pending state is cleared after alert flow. + // But the point is it didn't reset to 5 attempts before polling. + #expect(mockNetwork.pollRedemptionResultCallCount == 1) + } + + @Test("Stripe checkout complete uses default attempts for new context") + func testStripeCheckoutComplete_newContext_usesDefaultAttempts() async { + guard #available(iOS 14.0, *) else { + return + } + + let superwall = Superwall(dependencyContainer: dependencyContainer) + let mockStorage = StorageMock(internalRedeemResponse: nil) + let options = dependencyContainer.makeSuperwallOptions() + options.paywalls.shouldShowWebPurchaseConfirmationAlert = false + let mockNetwork = NetworkMock(options: options, factory: dependencyContainer) + mockNetwork.pollRedemptionResultResponses = [ + .success(RedeemResponse(results: [], customerInfo: .blank(), status: .failed)) + ] + + let redeemer = WebEntitlementRedeemer( + network: mockNetwork, + storage: mockStorage, + entitlementsInfo: dependencyContainer.entitlementsInfo, + delegate: dependencyContainer.delegateAdapter, + purchaseController: MockPurchaseController(), + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + superwall: superwall + ) + + // Existing state has different context + mockStorage.save( + PendingStripeCheckoutPollState( + checkoutContextId: "ctx_old", + productId: "prod_old", + remainingForegroundAttempts: 2 + ), + forType: PendingStripeCheckoutPollStorage.self + ) + + // checkout_complete with new context should get default 5 attempts + await redeemer.handleStripeCheckoutComplete(contextId: "ctx_new", productId: "prod_new") + + // Failed status clears pending state, but we can verify the poll happened + #expect(mockNetwork.pollRedemptionResultCallCount == 1) + } } From bc01587443a396ec61a76747b32fe26a76d6da50 Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Tue, 17 Feb 2026 17:33:17 +0100 Subject: [PATCH 07/11] feat: update event sending --- .../TrackableSuperwallEvent.swift | 40 +++++++++++++++++++ .../Superwall Placement/SuperwallEvent.swift | 20 ++++++++++ .../SuperwallEventObjc.swift | 20 ++++++++++ .../PaywallMessageHandler.swift | 27 ++++++------- 4 files changed, 93 insertions(+), 14 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index af3e889190..b181246da1 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -876,6 +876,46 @@ enum InternalSuperwallEvent { } } + struct StripeCheckout: TrackableSuperwallEvent { + enum State { + case start + case submit + case complete + case fail + } + let state: State + let productId: String + let paywallInfo: PaywallInfo + let placementData: PlacementData? + + var audienceFilterParams: [String: Any] { + return paywallInfo.audienceFilterParams() + } + + var superwallEvent: SuperwallEvent { + switch state { + case .start: + return .stripeCheckoutStart(paywallInfo: paywallInfo) + case .submit: + return .stripeCheckoutSubmit(paywallInfo: paywallInfo) + case .complete: + return .stripeCheckoutComplete(paywallInfo: paywallInfo) + case .fail: + return .stripeCheckoutFail(paywallInfo: paywallInfo) + } + } + + func getSuperwallParameters() async -> [String: Any] { + var params: [String: Any] = [ + "is_triggered_from_event": placementData != nil, + "store": "STRIPE", + "product_identifier": productId + ] + params += await paywallInfo.placementParams() + return params + } + } + enum ConfigCacheStatus: String { case cached = "CACHED" case notCached = "NOT_CACHED" diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift index 1b330cedae..459bbd8d3f 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift @@ -259,6 +259,18 @@ public enum SuperwallEvent { /// When paywall preloading completes. case paywallPreloadComplete(paywallCount: Int) + /// When a Stripe checkout session starts. + case stripeCheckoutStart(paywallInfo: PaywallInfo) + + /// When a Stripe checkout form is submitted. + case stripeCheckoutSubmit(paywallInfo: PaywallInfo) + + /// When a Stripe checkout session completes. + case stripeCheckoutComplete(paywallInfo: PaywallInfo) + + /// When a Stripe checkout session fails. + case stripeCheckoutFail(paywallInfo: PaywallInfo) + var canImplicitlyTriggerPaywall: Bool { switch self { case .appInstall, @@ -444,6 +456,14 @@ extension SuperwallEvent { return .init(objcEvent: .paywallPreloadStart) case .paywallPreloadComplete: return .init(objcEvent: .paywallPreloadComplete) + case .stripeCheckoutStart: + return .init(objcEvent: .stripeCheckoutStart) + case .stripeCheckoutSubmit: + return .init(objcEvent: .stripeCheckoutSubmit) + case .stripeCheckoutComplete: + return .init(objcEvent: .stripeCheckoutComplete) + case .stripeCheckoutFail: + return .init(objcEvent: .stripeCheckoutFail) } } } diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift index 92920f147c..bae06524ad 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift @@ -238,6 +238,18 @@ public enum SuperwallEventObjc: Int, CaseIterable { /// When paywall preloading completes. case paywallPreloadComplete + /// When a Stripe checkout session starts. + case stripeCheckoutStart + + /// When a Stripe checkout form is submitted. + case stripeCheckoutSubmit + + /// When a Stripe checkout session completes. + case stripeCheckoutComplete + + /// When a Stripe checkout session fails. + case stripeCheckoutFail + public init(event: SuperwallEvent) { self = event.backingData.objcEvent } @@ -388,6 +400,14 @@ public enum SuperwallEventObjc: Int, CaseIterable { return "paywallPreload_start" case .paywallPreloadComplete: return "paywallPreload_complete" + case .stripeCheckoutStart: + return "stripeCheckout_start" + case .stripeCheckoutSubmit: + return "stripeCheckout_submit" + case .stripeCheckoutComplete: + return "stripeCheckout_complete" + case .stripeCheckoutFail: + return "stripeCheckout_fail" } } } 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 4289ed75d3..fa3c60caa6 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 @@ -198,7 +198,7 @@ final class PaywallMessageHandler: WebEventDelegate { break case let .stripeCheckoutStart(checkoutContextId, productId): trackStripeCheckoutEvent( - eventName: "stripe_checkout_start", + state: .start, productId: productId ) delegate?.handleStripeCheckoutStart( @@ -207,7 +207,7 @@ final class PaywallMessageHandler: WebEventDelegate { ) case let .stripeCheckoutComplete(swCheckoutId, checkoutContextId, productId): trackStripeCheckoutEvent( - eventName: "stripe_checkout_complete", + state: .complete, productId: productId ) delegate?.handleStripeCheckoutComplete( @@ -217,7 +217,7 @@ final class PaywallMessageHandler: WebEventDelegate { ) case let .stripeCheckoutSubmit(checkoutContextId, productId): trackStripeCheckoutEvent( - eventName: "stripe_checkout_submit", + state: .submit, productId: productId ) delegate?.handleStripeCheckoutSubmit( @@ -226,7 +226,7 @@ final class PaywallMessageHandler: WebEventDelegate { ) case let .stripeCheckoutFail(_, productId): trackStripeCheckoutEvent( - eventName: "stripe_checkout_fail", + state: .fail, productId: productId ) // No-op: don't clear checkout context on failure @@ -542,20 +542,19 @@ final class PaywallMessageHandler: WebEventDelegate { } private func trackStripeCheckoutEvent( - eventName: String, + state: InternalSuperwallEvent.StripeCheckout.State, productId: String ) { - let params: [String: Any] = [ - "store": "STRIPE", - "product_identifier": productId - ] + guard let delegate = delegate else { return } + let paywallInfo = delegate.info + let placementData = delegate.request?.presentationInfo.placementData Task { - let event = UserInitiatedPlacement.Track( - rawName: eventName, - canImplicitlyTriggerPaywall: false, - audienceFilterParams: params, - isFeatureGatable: false + let event = InternalSuperwallEvent.StripeCheckout( + state: state, + productId: productId, + paywallInfo: paywallInfo, + placementData: placementData ) await Superwall.shared.track(event) } From 1254a8c184329e88285786a995419227eb8853b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:24:39 +0100 Subject: [PATCH 08/11] Bugfixes --- .../Superwall Placement/SuperwallEvent.swift | 7 ++- Sources/SuperwallKit/Network/Endpoint.swift | 2 +- .../PaywallViewController.swift | 49 +++++++----------- .../Message Handling/PaywallMessage.swift | 7 +-- .../PaywallMessageHandler.swift | 33 ++++++------ .../Web/WebEntitlementRedeemer.swift | 50 +++---------------- SuperwallKit.xcodeproj/project.pbxproj | 4 ++ .../PaywallMessageHandlerDelegateMock.swift | 5 +- .../PaywallMessageHandlerTests.swift | 3 -- 9 files changed, 57 insertions(+), 103 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift index fd1afb0840..51454fa9e8 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift @@ -84,8 +84,11 @@ public enum SuperwallEvent { /// this won't be `nil`. However, it could be `nil` if you are using a ``PurchaseController`` /// and the transaction object couldn't be detected after you return `.purchased` in ``PurchaseController/purchase(product:)``. case transactionComplete( - transaction: StoreTransaction?, product: StoreProduct, type: TransactionType, - paywallInfo: PaywallInfo) + transaction: StoreTransaction?, + product: StoreProduct, + type: TransactionType, + paywallInfo: PaywallInfo + ) /// When the user successfully completes a transaction for a subscription product with no introductory offers. case subscriptionStart(product: StoreProduct, paywallInfo: PaywallInfo) diff --git a/Sources/SuperwallKit/Network/Endpoint.swift b/Sources/SuperwallKit/Network/Endpoint.swift index 34e00f964f..7a4b9b9a49 100644 --- a/Sources/SuperwallKit/Network/Endpoint.swift +++ b/Sources/SuperwallKit/Network/Endpoint.swift @@ -378,7 +378,7 @@ extension Endpoint where return Endpoint( components: Components( - host: .web2app, + host: .subscriptionsApi, path: "checkout/session/poll-redemption-result", bodyData: bodyData ), diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index d5386b5b6d..20fbba3324 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -282,7 +282,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), webView.topAnchor.constraint(equalTo: view.topAnchor), - webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) } @@ -305,7 +305,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { exitButton.leadingAnchor.constraint( equalTo: view.layoutMarginsGuide.leadingAnchor, constant: 0), exitButton.widthAnchor.constraint(equalToConstant: 55), - exitButton.heightAnchor.constraint(equalToConstant: 55), + exitButton.heightAnchor.constraint(equalToConstant: 55) ]) } @@ -394,8 +394,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { loadingState = .loadingURL if let paywallArchiveManager = self.paywallArchiveManager, - paywallArchiveManager.shouldAlwaysUseWebArchive(manifest: paywall.manifest) - { + paywallArchiveManager.shouldAlwaysUseWebArchive(manifest: paywall.manifest) { Task { if let url = await paywallArchiveManager.getArchiveURL(forManifest: paywall.manifest) { loadWebViewFromArchive(url: url) @@ -609,7 +608,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { shimmerView.leadingAnchor.constraint(equalTo: shimmerSuperview.leadingAnchor), shimmerView.trailingAnchor.constraint(equalTo: shimmerSuperview.trailingAnchor), shimmerView.topAnchor.constraint(equalTo: shimmerSuperview.topAnchor), - shimmerView.bottomAnchor.constraint(equalTo: shimmerSuperview.bottomAnchor), + shimmerView.bottomAnchor.constraint(equalTo: shimmerSuperview.bottomAnchor) ]) self.shimmerView = shimmerView Task { @@ -636,7 +635,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { loadingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), loadingViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), loadingViewController.view.topAnchor.constraint(equalTo: view.topAnchor), - loadingViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + loadingViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) self.loadingViewController = loadingViewController } else { @@ -722,8 +721,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { ) { if Superwall.shared.isPaywallPresented || presenter is PaywallViewController - || isBeingPresented - { + || isBeingPresented { return completion(false) } Superwall.shared.presentationItems.window?.makeKeyAndVisible() @@ -746,8 +744,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { private func setPresentationStyle(withOverride override: PaywallPresentationStyle?) { if let override = override, - override != .none - { + override != .none { presentationStyle = override } else { presentationStyle = paywall.presentation.style @@ -763,12 +760,11 @@ public class PaywallViewController: UIViewController, LoadingDelegate { transitioningDelegate = transitionDelegate case .fullscreenNoAnimation: modalPresentationStyle = .overFullScreen - case .drawer(let height, let cornerRadius): + case let .drawer(height, cornerRadius): modalPresentationStyle = .pageSheet #if !os(visionOS) if #available(iOS 16.0, *), - UIDevice.current.userInterfaceIdiom == .phone - { + UIDevice.current.userInterfaceIdiom == .phone { let heightRatio = height / 100 sheetPresentationController?.detents = [ .custom { context in @@ -802,8 +798,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { if presentedView.layer.cornerRadius > 0 { targetView = presentedView } else if let superview = presentedView.superview, - superview.layer.cornerRadius > 0 - { + superview.layer.cornerRadius > 0 { targetView = superview } else { targetView = presentedView @@ -811,8 +806,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { let systemRadius = targetView.layer.cornerRadius if drawerDeviceCornerRadius == nil || drawerDeviceCornerRadius == 0, - systemRadius > 0 - { + systemRadius > 0 { drawerDeviceCornerRadius = systemRadius } let bottomRadius = drawerDeviceCornerRadius ?? systemRadius @@ -922,7 +916,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { backgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor), backgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor), backgroundView.topAnchor.constraint(equalTo: view.topAnchor), - backgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + backgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) // Add tap gesture to dismiss on background tap @@ -978,7 +972,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor), containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor), popupWidthConstraint, - popupHeightConstraint, + popupHeightConstraint ]) // Set webview constraints within container - fill the container exactly @@ -994,7 +988,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { webView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), webView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), webView.topAnchor.constraint(equalTo: containerView.topAnchor), - webView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + webView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) ]) } @@ -1053,8 +1047,7 @@ public class PaywallViewController: UIViewController, LoadingDelegate { var model: [Action] = [] if let actionTitle = actionTitle, - let action = action - { + let action = action { model = [Action(title: actionTitle, call: action)] } @@ -1158,8 +1151,7 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { // 3. No stripe checkout abandon message was received if !self.didRedeemSucceedDuringCheckout, !self.isCheckoutDismissedProgrammatically, - !self.didReceiveStripeCheckoutAbandonMessage - { + !self.didReceiveStripeCheckoutAbandonMessage { let workItem = DispatchWorkItem { [weak self] in guard let self = self else { return } Task { @@ -1370,8 +1362,7 @@ extension PaywallViewController { } if #available(iOS 15.0, *), - !deviceHelper.isMac - { + !deviceHelper.isMac { webView.setAllMediaPlaybackSuspended(false) // ignore-xcode-12 } @@ -1513,8 +1504,7 @@ extension PaywallViewController { } if #available(iOS 15.0, *), - !deviceHelper.isMac - { + !deviceHelper.isMac { webView.setAllMediaPlaybackSuspended(true) // ignore-xcode-12 } @@ -1562,8 +1552,7 @@ extension PaywallViewController { if case .paywall = presentationResult, !presentedByPaywallDecline, !presentedByTransactionAbandon, - !presentedByTransactionFail - { + !presentedByTransactionFail { // If a paywall_decline trigger is active and the current paywall wasn't presented // by paywall_decline, transaction_abandon, or transaction_fail, it lands here so // as not to dismiss the paywall. track() will do that before presenting the next paywall. 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 075cea9109..cb1c3f5f5e 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 @@ -42,6 +42,7 @@ enum ReviewType: String, Decodable { case external } +// swiftlint:disable:next type_body_length enum PaywallMessage: Decodable, Equatable { case onReady(paywallJsVersion: String) case templateParamsAndUserAttributes @@ -58,7 +59,6 @@ enum PaywallMessage: Decodable, Equatable { case initiateWebCheckout(contextId: String) case stripeCheckoutStart(checkoutContextId: String, productId: String) case stripeCheckoutComplete( - swCheckoutId: String, checkoutContextId: String, productId: String ) @@ -139,7 +139,6 @@ enum PaywallMessage: Decodable, Equatable { case reviewType case browserType case checkoutContextId - case swCheckoutId case type case title case subtitle @@ -232,11 +231,9 @@ enum PaywallMessage: Decodable, Equatable { return } case .stripeCheckoutComplete: - if let swCheckoutId = try? values.decode(String.self, forKey: .swCheckoutId), - let checkoutContextId = try? values.decode(String.self, forKey: .checkoutContextId), + if let checkoutContextId = try? values.decode(String.self, forKey: .checkoutContextId), let productId = try? values.decode(String.self, forKey: .productId) { self = .stripeCheckoutComplete( - swCheckoutId: swCheckoutId, checkoutContextId: checkoutContextId, productId: productId ) 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 7492dff9ed..9d9354be28 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 @@ -136,7 +136,7 @@ final class PaywallMessageHandler: WebEventDelegate { Task { await self.pass(placement: transactionStart, from: paywall) } - case .transactionComplete(let trialEndDate, let productIdentifier): + case let .transactionComplete(trialEndDate, productIdentifier): Task { // Send transaction_complete to trigger post-purchase actions let transactionComplete = SuperwallEventObjc.transactionComplete.description @@ -183,19 +183,19 @@ final class PaywallMessageHandler: WebEventDelegate { openDeepLink(url) case .restore: restorePurchases() - case .purchase(productId: let id, shouldDismiss: let shouldDismiss): + case let .purchase(productId: id, shouldDismiss: shouldDismiss): purchaseProduct(withId: id, shouldDismiss: shouldDismiss) case .custom(data: let name): handleCustomEvent(name) - case .customPlacement(name: let name, params: let params): + case let .customPlacement(name: name, params: params): handleCustomPlacement(name: name, params: params) - case .userAttributesUpdated(attributes: let attributes): + case let .userAttributesUpdated(attributes: attributes): handleUserAttributesUpdated(attributes: attributes) case .initiateWebCheckout: // No-op: This is only here for backwards compatibility so that we don't log // and error when decoding the message. break - case .stripeCheckoutStart(let checkoutContextId, let productId): + case let .stripeCheckoutStart(checkoutContextId, productId): trackStripeCheckoutEvent( state: .start, productId: productId @@ -204,7 +204,7 @@ final class PaywallMessageHandler: WebEventDelegate { checkoutContextId: checkoutContextId, productId: productId ) - case .stripeCheckoutComplete(_, let checkoutContextId, let productId): + case let .stripeCheckoutComplete(checkoutContextId, productId): trackStripeCheckoutEvent( state: .complete, productId: productId @@ -213,7 +213,7 @@ final class PaywallMessageHandler: WebEventDelegate { checkoutContextId: checkoutContextId, productId: productId ) - case .stripeCheckoutSubmit(let checkoutContextId, let productId): + case let .stripeCheckoutSubmit(checkoutContextId, productId): trackStripeCheckoutEvent( state: .submit, productId: productId @@ -222,19 +222,20 @@ final class PaywallMessageHandler: WebEventDelegate { checkoutContextId: checkoutContextId, productId: productId ) - case .stripeCheckoutFail(_, let productId): + case let .stripeCheckoutFail(_, productId): trackStripeCheckoutEvent( state: .fail, productId: productId ) // No-op: don't clear checkout context on failure - case .stripeCheckoutAbandon(let checkoutContextId, let productId): + case let .stripeCheckoutAbandon(checkoutContextId, productId): delegate?.handleStripeCheckoutAbandon( + checkoutContextId: checkoutContextId, productId: productId ) case .requestStoreReview(let reviewType): requestReview(type: reviewType) - case .scheduleNotification(let type, let title, let subtitle, let body, let delay): + case let .scheduleNotification(type, title, subtitle, body, delay): let notification = LocalNotification( type: type, title: title, @@ -243,13 +244,13 @@ final class PaywallMessageHandler: WebEventDelegate { delay: delay ) delegate?.eventDidOccur(.scheduleNotification(notification: notification)) - case .requestPermission(let permissionType, let requestId): + case let .requestPermission(permissionType, requestId): handleRequestPermission( permissionType: permissionType, requestId: requestId, paywall: paywall ) - case .requestCallback(let requestId, let name, let behavior, let variables): + case let .requestCallback(requestId, name, behavior, variables): handleRequestCallback( requestId: requestId, name: name, @@ -270,7 +271,7 @@ final class PaywallMessageHandler: WebEventDelegate { var event: [String: Any] = [ "event_name": placement, "paywall_id": paywall.databaseId, - "paywall_identifier": paywall.identifier, + "paywall_identifier": paywall.identifier ] event.merge(payload) { _, new in new } @@ -568,7 +569,7 @@ final class PaywallMessageHandler: WebEventDelegate { var info: [String: Any] = [ "self": self, "Superwall.shared.paywallViewController": paywallDebugDescription, - "event": eventName, + "event": eventName ] if let userInfo = userInfo { info = info.merging(userInfo) @@ -667,7 +668,7 @@ final class PaywallMessageHandler: WebEventDelegate { payload: [ "permission_type": permissionType.rawValue, "request_id": requestId, - "status": status.rawValue, + "status": status.rawValue ] ) } @@ -739,7 +740,7 @@ final class PaywallMessageHandler: WebEventDelegate { "event_name": "callback_result", "request_id": requestId, "name": name, - "status": status.rawValue, + "status": status.rawValue ] if let data { payload["data"] = data diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index 9073c87448..fce2106fd7 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -9,36 +9,6 @@ import Foundation import UIKit -struct PendingStripeCheckoutPollState: Codable, Equatable { - static let defaultForegroundAttempts = 5 - - let checkoutContextId: String - let productId: String - let remainingForegroundAttempts: Int - let updatedAt: Date - - init( - checkoutContextId: String, - productId: String, - remainingForegroundAttempts: Int = defaultForegroundAttempts, - updatedAt: Date = Date() - ) { - self.checkoutContextId = checkoutContextId - self.productId = productId - self.remainingForegroundAttempts = remainingForegroundAttempts - self.updatedAt = updatedAt - } - - func consumingForegroundAttempt() -> PendingStripeCheckoutPollState { - PendingStripeCheckoutPollState( - checkoutContextId: checkoutContextId, - productId: productId, - remainingForegroundAttempts: max(remainingForegroundAttempts - 1, 0), - updatedAt: updatedAt - ) - } -} - actor WebEntitlementRedeemer { private unowned let network: Network private unowned let storage: Storage @@ -178,7 +148,7 @@ actor WebEntitlementRedeemer { clearPendingStripeCheckoutState() return false } - guard !hasStripePendingTimedOut(state) else { + if hasStripePendingTimedOut(state) { clearPendingStripeCheckoutState() return false } @@ -238,8 +208,7 @@ actor WebEntitlementRedeemer { awaitingCheckoutComplete = false if let existingState = pendingStripeCheckoutState, - existingState.checkoutContextId == contextId - { + existingState.checkoutContextId == contextId { savePendingStripeCheckoutState( .init( checkoutContextId: contextId, @@ -584,8 +553,7 @@ actor WebEntitlementRedeemer { if case .success(_, let redemptionInfo) = codeResult, let product = redemptionInfo.paywallInfo?.product, product.trialPeriodDays > 0, - let paywallVc = superwall.paywallViewController - { + let paywallVc = superwall.paywallViewController { let paywallInfo = await paywallVc.info let notifications = paywallInfo.localNotifications.filter { $0.type == .trialStarted @@ -600,8 +568,7 @@ actor WebEntitlementRedeemer { if let paywallVc = superwall.paywallViewController, !paywallEntitlementIds.isEmpty, paywallEntitlementIds.subtracting(allEntitlementIds).isEmpty, - superwallOptions.paywalls.automaticallyDismiss - { + superwallOptions.paywalls.automaticallyDismiss { await superwall.dismiss(paywallVc, result: .restored) } @@ -789,8 +756,7 @@ actor WebEntitlementRedeemer { while !Task.isCancelled { if let state = pendingStripeCheckoutState, state.checkoutContextId == contextId, - hasStripePendingTimedOut(state) - { + hasStripePendingTimedOut(state) { clearPendingStripeCheckoutState() return .noRedemptionFound } @@ -896,8 +862,7 @@ actor WebEntitlementRedeemer { ) async { if !isFirstTime { if let entitlementsMaxAge = config?.web2appConfig?.entitlementsMaxAge - ?? factory.makeEntitlementsMaxAge() - { + ?? factory.makeEntitlementsMaxAge() { if let lastFetchedWebEntitlementsAt = storage.get(LastWebEntitlementsFetchDate.self) { let timeElapsed = Date().timeIntervalSince(lastFetchedWebEntitlementsAt) // Only proceed if a certain amount of time has elapsed @@ -989,8 +954,7 @@ actor WebEntitlementRedeemer { // If the restored entitlements cover the paywall entitlements, track and dismiss let activeEntitlementsIds = Set(activeEntitlements.map { $0.id }) if !paywallEntitlementIds.isEmpty, - paywallEntitlementIds.subtracting(activeEntitlementsIds).isEmpty - { + paywallEntitlementIds.subtracting(activeEntitlementsIds).isEmpty { let trackedEvent = await InternalSuperwallEvent.Restore( state: .complete, paywallInfo: paywallVc.info diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index d91fd35950..99b726a1df 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -119,6 +119,7 @@ 3313CD30A969731960FC32BF /* PaywallOverrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1439A212719AA2EA8BEA357 /* PaywallOverrides.swift */; }; 339F1D07DB57DBEC46940DB6 /* CheckoutWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0B401CD38DBD6D90E4EB3E /* CheckoutWebViewController.swift */; }; 342593FCA24FBEA77FE472C7 /* SK2ReceiptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050BC76657949DBB5F3D551C /* SK2ReceiptManager.swift */; }; + 3464196F9088F8A320FE24A4 /* PendingStripeCheckoutPollState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797EC0356AA1065ED11835BF /* PendingStripeCheckoutPollState.swift */; }; 35597883CB038DBEE63E162B /* EventData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86D76FB5809C3B8122778A9 /* EventData.swift */; }; 369677E9A6E8754CFD20714D /* TrackingParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 764012CF0C0972240A73E3CF /* TrackingParameters.swift */; }; 36B598D26A38D50CC713FD73 /* ProductsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641BC3C3F8AC2D6E1EF44D55 /* ProductsManager.swift */; }; @@ -824,6 +825,7 @@ 76D61D89D2905A8747E37458 /* InterfaceStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterfaceStyle.swift; sourceTree = ""; }; 787764B249892BBCA1088235 /* StorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageTests.swift; sourceTree = ""; }; 78C15CF29C17FE1EE3BFDEDC /* SubscriptionStatusResolutionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionStatusResolutionTests.swift; sourceTree = ""; }; + 797EC0356AA1065ED11835BF /* PendingStripeCheckoutPollState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingStripeCheckoutPollState.swift; sourceTree = ""; }; 79E2143AD65D151AE7A4BF0F /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; }; 7ABC4A0048583B47040C498B /* DispatchQueueBacked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchQueueBacked.swift; sourceTree = ""; }; 7B1CE50799F517D3D52A1BB9 /* PostbackAssignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostbackAssignment.swift; sourceTree = ""; }; @@ -2867,6 +2869,7 @@ E7A87DE5237B7AEDCCDA998C /* Web */ = { isa = PBXGroup; children = ( + 797EC0356AA1065ED11835BF /* PendingStripeCheckoutPollState.swift */, 22919EFD263425D38E7D9D38 /* WebEntitlementRedeemer.swift */, ); path = Web; @@ -3472,6 +3475,7 @@ B10030CC414C2C341487F4B8 /* PaywallViewControllerDelegateAdapter.swift in Sources */, 3824D48F8E0AF35EEBED8FF4 /* PaywallViewControllerWrapper.swift in Sources */, BF9ADF5761C34F584EC416B1 /* PaywallWebEvent.swift in Sources */, + 3464196F9088F8A320FE24A4 /* PendingStripeCheckoutPollState.swift in Sources */, E4A66E2235F7FE594EA82A06 /* PermissionHandler+Camera.swift in Sources */, 9735942D34369DA4412F8B63 /* PermissionHandler+Microphone.swift in Sources */, 746FCA3A2499F7BAF653F205 /* PermissionHandler+Notification.swift in Sources */, 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 0d3defd3ff..ec023fa450 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 @@ -46,7 +46,7 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { var didOpenPaymentSheet = false var stripeCheckoutStart: (checkoutContextId: String, productId: String)? var stripeCheckoutSubmit: (checkoutContextId: String, productId: String)? - var stripeCheckoutComplete: (swCheckoutId: String, checkoutContextId: String, productId: String)? + var stripeCheckoutComplete: (checkoutContextId: String, productId: String)? var stripeCheckoutAbandon: (checkoutContextId: String, productId: String)? var request: PresentationRequest? @@ -103,11 +103,10 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { } func handleStripeCheckoutComplete( - swCheckoutId: String, checkoutContextId: String, productId: String ) { - stripeCheckoutComplete = (swCheckoutId, checkoutContextId, productId) + stripeCheckoutComplete = (checkoutContextId, productId) } func handleStripeCheckoutAbandon(checkoutContextId: String, productId: String) { diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift index 61bce42105..e60d8303c4 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift @@ -619,7 +619,6 @@ struct PaywallMessageHandlerTests { let message = wrapped.payload.messages.first #expect(message == .stripeCheckoutComplete( - swCheckoutId: "sw_123", checkoutContextId: "ctx_123", productId: "prod_123" )) @@ -747,13 +746,11 @@ struct PaywallMessageHandlerTests { messageHandler.handle( .stripeCheckoutComplete( - swCheckoutId: "sw_123", checkoutContextId: "ctx_123", productId: "prod_123" ) ) - #expect(delegate.stripeCheckoutComplete?.swCheckoutId == "sw_123") #expect(delegate.stripeCheckoutComplete?.checkoutContextId == "ctx_123") #expect(delegate.stripeCheckoutComplete?.productId == "prod_123") } From 125cc1b42fdf51ce2f02c1e516ab1bc474d1ba60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:38:54 +0100 Subject: [PATCH 09/11] Fix tests --- .../Web/WebEntitlementRedeemer.swift | 14 +++++++------- .../Network/NetworkTests.swift | 18 +++++++++--------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index fce2106fd7..a4ba91d709 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -10,12 +10,12 @@ import Foundation import UIKit actor WebEntitlementRedeemer { - private unowned let network: Network - private unowned let storage: Storage - private unowned let entitlementsInfo: EntitlementsInfo - private unowned let delegate: SuperwallDelegateAdapter - private unowned let purchaseController: PurchaseController - private unowned let receiptManager: ReceiptManager + private let network: Network + private let storage: Storage + private let entitlementsInfo: EntitlementsInfo + private let delegate: SuperwallDelegateAdapter + private let purchaseController: PurchaseController + private let receiptManager: ReceiptManager private unowned let factory: Factory private let notificationScheduler: NotificationScheduling private let stripePendingPollIntervalNs: UInt64 @@ -158,7 +158,7 @@ actor WebEntitlementRedeemer { /// Either starts a new poll or waits for an existing in-flight poll to /// finish. Returns `true` if the checkout was redeemed. func pollOrWaitForActiveStripePoll() async -> Bool { - guard let pendingState = pendingStripeCheckoutState else { + if pendingStripeCheckoutState == nil { return false } diff --git a/Tests/SuperwallKitTests/Network/NetworkTests.swift b/Tests/SuperwallKitTests/Network/NetworkTests.swift index a2b49a232c..d92b43cf04 100644 --- a/Tests/SuperwallKitTests/Network/NetworkTests.swift +++ b/Tests/SuperwallKitTests/Network/NetworkTests.swift @@ -94,31 +94,31 @@ struct NetworkTests { #expect(urlSession.didRequest) } - func test_pollRedemptionResult_endpointBuildsRequest() async throws { + @Test func pollRedemptionResult_endpointBuildsRequest() async throws { let dependencyContainer = DependencyContainer() let request = PollRedemptionResultRequest( checkoutContextId: "ctx_123", deviceId: "device_123", appUserId: "user_123" ) - let endpoint = Endpoint.pollRedemptionResult(request: request) + let endpoint = Endpoint.pollRedemptionResult(request: request) let urlRequest = await endpoint.makeRequest( with: SuperwallRequestData(factory: dependencyContainer), factory: dependencyContainer ) - XCTAssertEqual(urlRequest?.httpMethod, "POST") - XCTAssertTrue( + #expect(urlRequest?.httpMethod == "POST") + #expect( urlRequest?.url?.absoluteString.contains( "/subscriptions-api/public/v1/checkout/session/poll-redemption-result" ) == true ) - let bodyData = try XCTUnwrap(urlRequest?.httpBody) - let bodyJson = try XCTUnwrap(try JSONSerialization.jsonObject(with: bodyData) as? [String: Any]) - XCTAssertEqual(bodyJson["checkoutContextId"] as? String, "ctx_123") - XCTAssertEqual(bodyJson["deviceId"] as? String, "device_123") - XCTAssertEqual(bodyJson["appUserId"] as? String, "user_123") + let bodyData = try #require(urlRequest?.httpBody) + let bodyJson = try #require(try JSONSerialization.jsonObject(with: bodyData) as? [String: Any]) + #expect(bodyJson["checkoutContextId"] as? String == "ctx_123") + #expect(bodyJson["deviceId"] as? String == "device_123") + #expect(bodyJson["appUserId"] as? String == "user_123") } } From ebaeed7296473b100be1a8ffe7b8ead112ec4ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:42:04 +0100 Subject: [PATCH 10/11] Add comments and remove redundant code --- .../PaywallViewController.swift | 2 -- .../PaywallMessageHandler.swift | 7 +---- .../Web/WebEntitlementRedeemer.swift | 10 +++---- .../PaywallMessageHandlerDelegateMock.swift | 5 ---- .../PaywallMessageHandlerTests.swift | 27 ------------------- 5 files changed, 5 insertions(+), 46 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift index 20fbba3324..4ce5be57ff 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift @@ -1193,8 +1193,6 @@ extension PaywallViewController: PaywallMessageHandlerDelegate { #endif } - func handleStripeCheckoutStart(checkoutContextId _: String, productId _: String) {} - func handleStripeCheckoutSubmit(checkoutContextId: String, productId: String) { Task { await 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 9d9354be28..657bdff35f 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,7 +23,6 @@ protocol PaywallMessageHandlerDelegate: AnyObject { func presentSafariExternal(_ url: URL) func requestReview(type: ReviewType) func openPaymentSheet(_ url: URL) - func handleStripeCheckoutStart(checkoutContextId: String, productId: String) func handleStripeCheckoutSubmit(checkoutContextId: String, productId: String) func handleStripeCheckoutComplete( checkoutContextId: String, @@ -195,15 +194,11 @@ final class PaywallMessageHandler: WebEventDelegate { // No-op: This is only here for backwards compatibility so that we don't log // and error when decoding the message. break - case let .stripeCheckoutStart(checkoutContextId, productId): + case let .stripeCheckoutStart(_, productId): trackStripeCheckoutEvent( state: .start, productId: productId ) - delegate?.handleStripeCheckoutStart( - checkoutContextId: checkoutContextId, - productId: productId - ) case let .stripeCheckoutComplete(checkoutContextId, productId): trackStripeCheckoutEvent( state: .complete, diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index a4ba91d709..5e08583042 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -116,9 +116,6 @@ actor WebEntitlementRedeemer { // Also check once on SDK initialization so pending Stripe checkouts can be // recovered on cold launch. Task { - guard factory.makeConfigManager() != nil else { - return - } await pollPendingStripeCheckoutOnForegroundIfNeeded() } } @@ -260,6 +257,8 @@ actor WebEntitlementRedeemer { await superwall.track(event) } + /// Polls for the redemption result of a pending Stripe checkout, if one exists, + /// decrementing the remaining foreground attempts and clearing state when exhausted. func pollPendingStripeCheckoutOnForegroundIfNeeded() async { guard let pendingState = pendingStripeCheckoutState else { return @@ -812,10 +811,9 @@ actor WebEntitlementRedeemer { } } + /// Called on foreground. Polls for pending Stripe checkout redemption (showing a + /// loading spinner on the paywall if needed) and then refreshes web entitlements. private func handleForegroundPolling() async { - if factory.makeConfigManager() == nil { - return - } let superwall = superwall ?? Superwall.shared let hasVisiblePaywall = await MainActor.run { superwall.paywallViewController != 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 ec023fa450..22de441ddf 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 @@ -44,7 +44,6 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { var didPresentSafariExternal = false var didRequestReview = false var didOpenPaymentSheet = false - var stripeCheckoutStart: (checkoutContextId: String, productId: String)? var stripeCheckoutSubmit: (checkoutContextId: String, productId: String)? var stripeCheckoutComplete: (checkoutContextId: String, productId: String)? var stripeCheckoutAbandon: (checkoutContextId: String, productId: String)? @@ -94,10 +93,6 @@ final class PaywallMessageHandlerDelegateMock: PaywallMessageHandlerDelegate { didOpenPaymentSheet = true } - func handleStripeCheckoutStart(checkoutContextId: String, productId: String) { - stripeCheckoutStart = (checkoutContextId, productId) - } - func handleStripeCheckoutSubmit(checkoutContextId: String, productId: String) { stripeCheckoutSubmit = (checkoutContextId, productId) } diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift index e60d8303c4..c11c13d1e3 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift @@ -696,33 +696,6 @@ struct PaywallMessageHandlerTests { #expect(message == .stripeCheckoutAbandon(checkoutContextId: "ctx_123", productId: "prod_123")) } - @Test - func handleStripeCheckoutStart_forwardsToDelegate() { - let dependencyContainer = DependencyContainer() - let messageHandler = PaywallMessageHandler( - receiptManager: dependencyContainer.receiptManager, - factory: dependencyContainer, - permissionHandler: FakePermissionHandler(), - customCallbackRegistry: dependencyContainer.customCallbackRegistry - ) - let webView = FakeWebView( - isMac: false, - messageHandler: messageHandler, - isOnDeviceCacheEnabled: true, - factory: dependencyContainer - ) - let delegate = PaywallMessageHandlerDelegateMock( - paywallInfo: .stub(), - webView: webView - ) - messageHandler.delegate = delegate - - messageHandler.handle(.stripeCheckoutStart(checkoutContextId: "ctx_123", productId: "prod_123")) - - #expect(delegate.stripeCheckoutStart?.checkoutContextId == "ctx_123") - #expect(delegate.stripeCheckoutStart?.productId == "prod_123") - } - @Test func handleStripeCheckoutComplete_forwardsToDelegate() { let dependencyContainer = DependencyContainer() From 22cc1eb0840ae9a02b05f6087d0621648303f58b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:03:28 +0100 Subject: [PATCH 11/11] Fix tests --- .../Dependencies/DependencyContainer.swift | 12 +- .../Dependencies/FactoryProtocols.swift | 3 +- .../Web/WebEntitlementRedeemer.swift | 128 ++++++++---------- .../PaywallMessageHandlerTests.swift | 1 - .../Web/WebEntitlementRedeemerTests.swift | 7 + 5 files changed, 76 insertions(+), 75 deletions(-) diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index 5d30511e2d..7227864365 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -484,10 +484,6 @@ extension DependencyContainer: ConfigManagerFactory { deviceLocale: deviceInfo.locale ) } - - func makeConfigManager() -> ConfigManager? { - return configManager - } } // MARK: - StoreTransactionFactory @@ -624,6 +620,14 @@ extension DependencyContainer: ConfigAttributesFactory { // MARK: WebEntitlementFactory extension DependencyContainer: WebEntitlementFactory { + /// Properties like `deviceHelper` are implicitly unwrapped optionals set after + /// init. Tests create a bare `DependencyContainer` without fully configuring it, + /// so background tasks in `WebEntitlementRedeemer` must check this before + /// accessing factory methods to avoid a nil dereference. + func makeIsContainerReady() -> Bool { + return configManager != nil + } + func makeDeviceId() -> String { return "$SuperwallDevice:\(deviceHelper.vendorId)" } diff --git a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift index 8c723032bb..9f8aee4647 100644 --- a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift +++ b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift @@ -78,8 +78,6 @@ protocol ConfigManagerFactory: AnyObject { withId paywallId: String?, isDebuggerLaunched: Bool ) -> Paywall? - - func makeConfigManager() -> ConfigManager? } protocol IdentityFactory: AnyObject { @@ -166,6 +164,7 @@ protocol ConfigAttributesFactory: AnyObject { } protocol WebEntitlementFactory: AnyObject { + func makeIsContainerReady() -> Bool func makeDeviceId() -> String func makeAppUserId() -> String? func makeAliasId() -> String diff --git a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift index 5e08583042..6625b6dccf 100644 --- a/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift +++ b/Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift @@ -21,13 +21,12 @@ actor WebEntitlementRedeemer { private let stripePendingPollIntervalNs: UInt64 private let stripePendingPollTimeoutNs: UInt64 private var isProcessing = false - private var activeStripePollContextId: String? + private var hasActiveStripePoll = false private var awaitingCheckoutComplete = false private var superwall: Superwall? typealias Factory = WebEntitlementFactory & OptionsFactory & ConfigStateFactory - & ConfigManagerFactory & HasExternalPurchaseControllerFactory & DeviceHelperFactory @@ -114,8 +113,10 @@ actor WebEntitlementRedeemer { ) // Also check once on SDK initialization so pending Stripe checkouts can be - // recovered on cold launch. + // recovered on cold launch. Guard on factory readiness to avoid accessing + // dependencies (e.g. deviceHelper) before the container is fully set up. Task { + guard factory.makeIsContainerReady() else { return } await pollPendingStripeCheckoutOnForegroundIfNeeded() } } @@ -133,10 +134,6 @@ actor WebEntitlementRedeemer { ) } - func hasActiveStripePoll() -> Bool { - activeStripePollContextId != nil - } - func shouldShowStripeRecoveryLoadingOnPaywallOpen() -> Bool { guard let state = pendingStripeCheckoutState else { return false @@ -161,9 +158,9 @@ actor WebEntitlementRedeemer { // If there's already an active poll (e.g. from cold-launch init task), // wait for it to finish rather than skipping. - if activeStripePollContextId != nil { + if hasActiveStripePoll { let waitStart = DispatchTime.now().uptimeNanoseconds - while activeStripePollContextId != nil { + while hasActiveStripePoll { if Task.isCancelled { return false } @@ -398,15 +395,13 @@ actor WebEntitlementRedeemer { } private func trackRedemptionStart(type: RedeemType, superwall: Superwall) async { - guard case .code = type else { - if case .existingCodes = type { - let startEvent = InternalSuperwallEvent.Redemption(state: .start, type: type) - await superwall.track(startEvent) - } - return + switch type { + case .code, .existingCodes: + let startEvent = InternalSuperwallEvent.Redemption(state: .start, type: type) + await superwall.track(startEvent) + case .integrationAttributes: + break } - let startEvent = InternalSuperwallEvent.Redemption(state: .start, type: type) - await superwall.track(startEvent) } private func prepareUIForRedemption(type: RedeemType, superwall: Superwall) async { @@ -426,12 +421,12 @@ actor WebEntitlementRedeemer { ) async { storage.save(Date(), forType: LastWebEntitlementsFetchDate.self) - if case .code = type { - let completeEvent = InternalSuperwallEvent.Redemption(state: .complete, type: type) - await superwall.track(completeEvent) - } else if case .existingCodes = type { + switch type { + case .code, .existingCodes: let completeEvent = InternalSuperwallEvent.Redemption(state: .complete, type: type) await superwall.track(completeEvent) + case .integrationAttributes: + break } let (allEntitlements, paywallEntitlementIds) = await processEntitlements( @@ -442,24 +437,10 @@ actor WebEntitlementRedeemer { storage.save(response, forType: LatestRedeemResponse.self) - // Merge device and web CustomerInfo - // If using an external purchase controller, also preserve entitlements that came from it - let mergedCustomerInfo: CustomerInfo - if factory.makeHasExternalPurchaseController() { - let subscriptionStatus = await MainActor.run { superwall.subscriptionStatus } - mergedCustomerInfo = CustomerInfo.forExternalPurchaseController( - storage: storage, - subscriptionStatus: subscriptionStatus - ) - } else { - let deviceCustomerInfo = storage.get(LatestDeviceCustomerInfo.self) ?? .blank() - mergedCustomerInfo = deviceCustomerInfo.merging(with: response.customerInfo) - } - - // Update Superwall's CustomerInfo with the merged result - await MainActor.run { - superwall.customerInfo = mergedCustomerInfo - } + _ = await mergeAndApplyCustomerInfo( + webCustomerInfo: response.customerInfo, + superwall: superwall + ) await updateSubscriptionStatus(with: allEntitlements, superwall: superwall) @@ -518,6 +499,32 @@ actor WebEntitlementRedeemer { return (allEntitlements, paywallEntitlementIds) } + /// Merges device and web entitlements into a single `CustomerInfo`, applying + /// external purchase controller logic when needed, and assigns it to + /// `superwall.customerInfo`. + private func mergeAndApplyCustomerInfo( + webCustomerInfo: CustomerInfo, + superwall: Superwall + ) async -> CustomerInfo { + let mergedCustomerInfo: CustomerInfo + if factory.makeHasExternalPurchaseController() { + let subscriptionStatus = await MainActor.run { superwall.subscriptionStatus } + mergedCustomerInfo = CustomerInfo.forExternalPurchaseController( + storage: storage, + subscriptionStatus: subscriptionStatus + ) + } else { + let deviceCustomerInfo = storage.get(LatestDeviceCustomerInfo.self) ?? .blank() + mergedCustomerInfo = deviceCustomerInfo.merging(with: webCustomerInfo) + } + + await MainActor.run { + superwall.customerInfo = mergedCustomerInfo + } + + return mergedCustomerInfo + } + private func updateSubscriptionStatus( with allEntitlements: Set, superwall: Superwall @@ -659,12 +666,12 @@ actor WebEntitlementRedeemer { latestRedeemResponse: RedeemResponse?, superwall: Superwall ) async { - if case .code = type { - let event = InternalSuperwallEvent.Redemption(state: .fail, type: type) - await superwall.track(event) - } else if case .existingCodes = type { + switch type { + case .code, .existingCodes: let event = InternalSuperwallEvent.Redemption(state: .fail, type: type) await superwall.track(event) + case .integrationAttributes: + break } if case .code(let code) = type { @@ -725,7 +732,7 @@ actor WebEntitlementRedeemer { storage.delete(PendingStripeCheckoutPollStorage.self) } - private func createPollRedemptionRequest( + private func makePollRedemptionRequest( contextId: String ) -> PollRedemptionResultRequest { return PollRedemptionResultRequest( @@ -740,16 +747,16 @@ actor WebEntitlementRedeemer { productId: String, trigger: StripePollTrigger ) async -> StripePollOutcome { - if activeStripePollContextId != nil { + if hasActiveStripePoll { return .skippedInFlight } - activeStripePollContextId = contextId + hasActiveStripePoll = true defer { - activeStripePollContextId = nil + hasActiveStripePoll = false } - let request = createPollRedemptionRequest(contextId: contextId) + let request = makePollRedemptionRequest(contextId: contextId) let startedAt = DispatchTime.now().uptimeNanoseconds while !Task.isCancelled { @@ -814,6 +821,7 @@ actor WebEntitlementRedeemer { /// Called on foreground. Polls for pending Stripe checkout redemption (showing a /// loading spinner on the paywall if needed) and then refreshes web entitlements. private func handleForegroundPolling() async { + guard factory.makeIsContainerReady() else { return } let superwall = superwall ?? Superwall.shared let hasVisiblePaywall = await MainActor.run { superwall.paywallViewController != nil } @@ -909,27 +917,11 @@ actor WebEntitlementRedeemer { return } - // Merge device and web CustomerInfo - // If using an external purchase controller, also preserve entitlements that came from it - let mergedCustomerInfo: CustomerInfo - if factory.makeHasExternalPurchaseController() { - let subscriptionStatus = await MainActor.run { superwall.subscriptionStatus } - mergedCustomerInfo = CustomerInfo.forExternalPurchaseController( - storage: storage, - subscriptionStatus: subscriptionStatus - ) - } else { - let deviceCustomerInfo = storage.get(LatestDeviceCustomerInfo.self) ?? .blank() - mergedCustomerInfo = deviceCustomerInfo.merging(with: response.customerInfo) - } - - // Update Superwall's CustomerInfo with the merged result - await MainActor.run { - superwall.customerInfo = mergedCustomerInfo - } + let mergedCustomerInfo = await mergeAndApplyCustomerInfo( + webCustomerInfo: response.customerInfo, + superwall: superwall + ) - // Sets the subscription status internally if no external PurchaseController - // Use the merged entitlements from CustomerInfo (already prioritized) let activeEntitlements = Set(mergedCustomerInfo.entitlements.filter { $0.isActive }) if activeEntitlements.isEmpty { await superwall.internallySetSubscriptionStatus(to: .inactive, superwall: superwall) diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift index c11c13d1e3..7abccfa77b 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift @@ -778,7 +778,6 @@ struct PaywallMessageHandlerTests { messageHandler.handle(.stripeCheckoutFail(checkoutContextId: "ctx_123", productId: "prod_123")) - #expect(delegate.stripeCheckoutStart == nil) #expect(delegate.stripeCheckoutSubmit == nil) #expect(delegate.stripeCheckoutComplete == nil) #expect(delegate.stripeCheckoutAbandon == nil) diff --git a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift index 9c06f39aa1..e72cc30b5c 100644 --- a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift +++ b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift @@ -23,9 +23,16 @@ final class NotificationSchedulerMock: NotificationScheduling { } } +@Suite(.serialized) struct WebEntitlementRedeemerTests { let dependencyContainer = DependencyContainer() + init() { + // Clear any pending stripe checkout state left on disk by a previous test + // to prevent the WebEntitlementRedeemer init Task from triggering unexpected saves. + dependencyContainer.storage.delete(PendingStripeCheckoutPollStorage.self) + } + @Test("First redemption of code") func testRedeem_withCode_firstRedemption_savesCodeAndTracksEvents() async { guard #available(iOS 14.0, *) else {