From 095fbfb70403f00dda0b700706d619921ecb9ba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:52:28 +0100 Subject: [PATCH 1/9] feat: switch v2Products endpoint to subscriptions API (web2app) Add CodingKeys for snake_case decoding since Web2App decoder doesn't auto-convert from snake case. Co-Authored-By: Claude Opus 4.5 --- Sources/SuperwallKit/Network/Endpoint.swift | 17 ++ Sources/SuperwallKit/Network/Network.swift | 21 +++ .../Network/V2ProductsResponse.swift | 158 ++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 Sources/SuperwallKit/Network/V2ProductsResponse.swift diff --git a/Sources/SuperwallKit/Network/Endpoint.swift b/Sources/SuperwallKit/Network/Endpoint.swift index 1d80a87012..61b976ea3b 100644 --- a/Sources/SuperwallKit/Network/Endpoint.swift +++ b/Sources/SuperwallKit/Network/Endpoint.swift @@ -392,3 +392,20 @@ extension Endpoint where ) } } + +// MARK: - V2 Products +extension Endpoint where + Kind == EndpointKinds.Web2App, + Response == V2ProductsResponse { + /// Fetches all products from the subscriptions API. + /// The application is inferred from the SDK's public API key. + static func v2Products() -> Self { + return Endpoint( + components: Components( + host: .web2app, + path: "products" + ), + method: .get + ) + } +} diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index 26ff43e315..1e0d9963f2 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -361,4 +361,25 @@ class Network { data: SuperwallRequestData(factory: factory) ).tokensByProductId } + + /// Fetches all products from the subscriptions API. + /// The application is inferred from the SDK's public API key. + /// + /// - Returns: A response containing all products for this application. + func getV2Products() async throws -> V2ProductsResponse { + do { + return try await urlSession.request( + .v2Products(), + data: SuperwallRequestData(factory: factory) + ) + } catch { + Logger.debug( + logLevel: .error, + scope: .network, + message: "Request Failed: /v1/products", + error: error + ) + throw error + } + } } diff --git a/Sources/SuperwallKit/Network/V2ProductsResponse.swift b/Sources/SuperwallKit/Network/V2ProductsResponse.swift new file mode 100644 index 0000000000..b20d31305c --- /dev/null +++ b/Sources/SuperwallKit/Network/V2ProductsResponse.swift @@ -0,0 +1,158 @@ +// +// V2ProductsResponse.swift +// Superwall +// +// Created by Claude on 2026-01-26. +// + +import Foundation + +/// Response from the /v2/products endpoint containing a list of products. +struct V2ProductsResponse: Decodable { + /// The list of products. + let data: [V2Product] +} + +/// A product from the Superwall catalog (v2 API). +public struct V2Product: Decodable, Sendable { + /// The unique identifier for the product. + public let id: Int + + /// The type of object (always "product"). + public let object: String + + /// The application ID this product belongs to. + public let application: Int + + /// The product identifier (e.g., App Store product ID). + public let identifier: String + + /// The display name of the product. + public let name: String? + + /// The platform this product is for. + public let platform: V2ProductPlatform + + /// The price of the product. + public let price: V2ProductPrice? + + /// Subscription details if this is a subscription product. + public let subscription: V2ProductSubscription? + + /// The entitlement IDs associated with this product. + public let entitlements: [Int] + + /// When the product was created. + public let createdAt: String + + /// When the product was last updated. + public let updatedAt: String + + /// Arbitrary metadata associated with the product. + public let metadata: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case id, object, application, identifier, name, platform + case price, subscription, entitlements, metadata + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +/// The platform a product is available on. +public enum V2ProductPlatform: String, Decodable, Sendable { + case ios + case android + case stripe + case paddle +} + +/// Price information for a product. +public struct V2ProductPrice: Decodable, Sendable { + /// The price amount in cents. + public let amount: Int + + /// The currency code (e.g., "USD"). + public let currency: String +} + +/// Subscription details for a product. +public struct V2ProductSubscription: Decodable, Sendable { + /// The subscription period unit. + public let period: V2SubscriptionPeriod + + /// The number of periods in each billing cycle. + public let periodCount: Int + + /// The number of trial days, if any. + public let trialPeriodDays: Int? + + /// Introductory offer details, if any. + public let introductoryOffer: V2IntroductoryOffer? + + enum CodingKeys: String, CodingKey { + case period + case periodCount = "period_count" + case trialPeriodDays = "trial_period_days" + case introductoryOffer = "introductory_offer" + } +} + +/// The unit of a subscription period. +public enum V2SubscriptionPeriod: String, Decodable, Sendable { + case day + case week + case month + case year +} + +/// Introductory offer details. +public struct V2IntroductoryOffer: Decodable, Sendable { + /// The type of introductory offer. + public let type: V2IntroOfferType + + /// The duration of the offer in days. + public let durationDays: Int + + enum CodingKeys: String, CodingKey { + case type + case durationDays = "duration_days" + } +} + +/// The type of introductory offer. +public enum V2IntroOfferType: String, Decodable, Sendable { + case freeTrial = "free_trial" + case payAsYouGo = "pay_as_you_go" + case payUpFront = "pay_up_front" +} + +/// A type-erased Codable value for handling arbitrary JSON. +public struct AnyCodable: Decodable, @unchecked Sendable { + public let value: Any + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + value = NSNull() + } else if let bool = try? container.decode(Bool.self) { + value = bool + } else if let int = try? container.decode(Int.self) { + value = int + } else if let double = try? container.decode(Double.self) { + value = double + } else if let string = try? container.decode(String.self) { + value = string + } else if let array = try? container.decode([AnyCodable].self) { + value = array.map { $0.value } + } else if let dict = try? container.decode([String: AnyCodable].self) { + value = dict.mapValues { $0.value } + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unable to decode value" + ) + } + } +} From 142877434684ab6bdbec9f7c3eae3a36fdbd4e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:11:29 +0100 Subject: [PATCH 2/9] feat: add TestStoreProduct conforming to StoreProductType Maps V2Product API response data into a StoreProductType for use as a test store product backed by the Superwall API. Co-Authored-By: Claude Opus 4.5 --- .../StoreProduct/TestStoreProduct.swift | 347 ++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 Sources/SuperwallKit/StoreKit/Products/StoreProduct/TestStoreProduct.swift diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/TestStoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/TestStoreProduct.swift new file mode 100644 index 0000000000..446763f871 --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/TestStoreProduct.swift @@ -0,0 +1,347 @@ +// +// TestStoreProduct.swift +// SuperwallKit +// +// Created by Yusuf Tör on 2026-01-27. +// +// swiftlint:disable type_body_length file_length + +import Foundation +import StoreKit + +/// A `StoreProductType` backed by a `V2Product` from the Superwall API. +/// +/// Used for test store products that are not fetched from StoreKit. +struct TestStoreProduct: StoreProductType { + let v2Product: V2Product + let entitlements: Set + + private let priceFormatterProvider = PriceFormatterProvider() + + private var subscriptionUnit: SubscriptionPeriod.Unit? { + guard let sub = v2Product.subscription else { return nil } + switch sub.period { + case .day: return .day + case .week: return .week + case .month: return .month + case .year: return .year + } + } + + private var subscriptionValue: Int { + v2Product.subscription?.periodCount ?? 0 + } + + var productIdentifier: String { + v2Product.identifier + } + + var price: Decimal { + guard let amount = v2Product.price?.amount else { return 0 } + // amount is in cents + return Decimal(amount) / 100 + } + + var localizedPrice: String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = v2Product.price?.currency.uppercased() ?? "USD" + return formatter.string(from: NSDecimalNumber(decimal: price)) ?? "$\(price)" + } + + var currencyCode: String? { + v2Product.price?.currency.uppercased() + } + + var currencySymbol: String? { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currencyCode ?? "USD" + return formatter.currencySymbol + } + + let subscriptionGroupIdentifier: String? = nil + + var swProduct: SWProduct { + SWProduct(product: self) + } + + var localizedSubscriptionPeriod: String { + guard let unit = subscriptionUnit else { return "" } + let dateComponents: DateComponents + switch unit { + case .day: dateComponents = DateComponents(day: subscriptionValue) + case .week: dateComponents = DateComponents(weekOfMonth: subscriptionValue) + case .month: dateComponents = DateComponents(month: subscriptionValue) + case .year: dateComponents = DateComponents(year: subscriptionValue) + @unknown default: dateComponents = DateComponents(month: subscriptionValue) + } + return DateComponentsFormatter.localizedString(from: dateComponents, unitsStyle: .short) ?? "" + } + + var period: String { + guard let unit = subscriptionUnit else { return "" } + switch unit { + case .day: + return subscriptionValue == 7 ? "week" : "day" + case .week: + return "week" + case .month: + switch subscriptionValue { + case 2: return "2 months" + case 3: return "quarter" + case 6: return "6 months" + default: return "month" + } + case .year: + return "year" + @unknown default: + return "" + } + } + + var periodly: String { + guard let unit = subscriptionUnit else { return "" } + if unit == .month { + switch subscriptionValue { + case 2, 6: + return "every \(period)" + default: + break + } + } + return "\(period)ly" + } + + var periodWeeks: Int { + guard let unit = subscriptionUnit else { return 0 } + switch unit { + case .day: return subscriptionValue / 7 + case .week: return subscriptionValue + case .month: return 4 * subscriptionValue + case .year: return 52 * subscriptionValue + @unknown default: return 0 + } + } + + var periodWeeksString: String { "\(periodWeeks)" } + + var periodMonths: Int { + guard let unit = subscriptionUnit else { return 0 } + switch unit { + case .day: return subscriptionValue / 30 + case .week: return subscriptionValue / 4 + case .month: return subscriptionValue + case .year: return 12 * subscriptionValue + @unknown default: return 0 + } + } + + var periodMonthsString: String { "\(periodMonths)" } + + var periodYears: Int { + guard let unit = subscriptionUnit else { return 0 } + switch unit { + case .day: return subscriptionValue / 365 + case .week: return subscriptionValue / 52 + case .month: return subscriptionValue / 12 + case .year: return subscriptionValue + @unknown default: return 0 + } + } + + var periodYearsString: String { "\(periodYears)" } + + var periodDays: Int { + guard let unit = subscriptionUnit else { return 0 } + switch unit { + case .day: return subscriptionValue + case .week: return 7 * subscriptionValue + case .month: return 30 * subscriptionValue + case .year: return 365 * subscriptionValue + @unknown default: return 0 + } + } + + var periodDaysString: String { "\(periodDays)" } + + // MARK: - Computed Prices + + private var priceFormatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currencyCode ?? "USD" + return formatter + } + + var dailyPrice: String { + guard price != 0, let unit = subscriptionUnit else { + return priceFormatter.string(from: 0) ?? "$0.00" + } + let days: Decimal + switch unit { + case .day: days = Decimal(subscriptionValue) + case .week: days = Decimal(7 * subscriptionValue) + case .month: days = Decimal(30 * subscriptionValue) + case .year: days = Decimal(365 * subscriptionValue) + @unknown default: days = 1 + } + return priceFormatter.string(from: NSDecimalNumber(decimal: price / days)) ?? "n/a" + } + + var weeklyPrice: String { + guard price != 0, let unit = subscriptionUnit else { + return priceFormatter.string(from: 0) ?? "$0.00" + } + let weeks: Decimal + switch unit { + case .day: weeks = Decimal(subscriptionValue) / 7 + case .week: weeks = Decimal(subscriptionValue) + case .month: weeks = Decimal(4 * subscriptionValue) + case .year: weeks = Decimal(52 * subscriptionValue) + @unknown default: weeks = 1 + } + return priceFormatter.string(from: NSDecimalNumber(decimal: price / weeks)) ?? "n/a" + } + + var monthlyPrice: String { + guard price != 0, let unit = subscriptionUnit else { + return priceFormatter.string(from: 0) ?? "$0.00" + } + let months: Decimal + switch unit { + case .day: months = Decimal(subscriptionValue) / 30 + case .week: months = Decimal(subscriptionValue) / 4 + case .month: months = Decimal(subscriptionValue) + case .year: months = Decimal(12 * subscriptionValue) + @unknown default: months = 1 + } + return priceFormatter.string(from: NSDecimalNumber(decimal: price / months)) ?? "n/a" + } + + var yearlyPrice: String { + guard price != 0, let unit = subscriptionUnit else { + return priceFormatter.string(from: 0) ?? "$0.00" + } + let years: Decimal + switch unit { + case .day: years = Decimal(subscriptionValue) / 365 + case .week: years = Decimal(subscriptionValue) / 52 + case .month: years = Decimal(subscriptionValue) / 12 + case .year: years = Decimal(subscriptionValue) + @unknown default: years = 1 + } + return priceFormatter.string(from: NSDecimalNumber(decimal: price / years)) ?? "n/a" + } + + // MARK: - Trial + + var hasFreeTrial: Bool { + guard let trialDays = v2Product.subscription?.trialPeriodDays else { return false } + return trialDays > 0 + } + + var trialPeriodEndDate: Date? { + guard let trialDays = v2Product.subscription?.trialPeriodDays, trialDays > 0 else { + return nil + } + return Calendar.current.date(byAdding: .day, value: trialDays, to: Date()) + } + + var trialPeriodEndDateString: String { + guard let date = trialPeriodEndDate else { return "" } + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + formatter.locale = .autoupdatingCurrent + return formatter.string(from: date) + } + + var localizedTrialPeriodPrice: String { + priceFormatter.string(from: 0) ?? "$0.00" + } + + var trialPeriodPrice: Decimal { 0 } + + func trialPeriodPricePerUnit(_ unit: SubscriptionPeriod.Unit) -> String { + priceFormatter.string(from: 0) ?? "$0.00" + } + + var trialPeriodDays: Int { + v2Product.subscription?.trialPeriodDays ?? 0 + } + + var trialPeriodDaysString: String { "\(trialPeriodDays)" } + + var trialPeriodWeeks: Int { + trialPeriodDays / 7 + } + + var trialPeriodWeeksString: String { "\(trialPeriodWeeks)" } + + var trialPeriodMonths: Int { + trialPeriodDays / 30 + } + + var trialPeriodMonthsString: String { "\(trialPeriodMonths)" } + + var trialPeriodYears: Int { + trialPeriodDays / 365 + } + + var trialPeriodYearsString: String { "\(trialPeriodYears)" } + + var trialPeriodText: String { + guard trialPeriodDays > 0 else { return "" } + return "\(trialPeriodDays)-day" + } + + // MARK: - Locale + + var locale: String { + Locale.current.identifier + } + + var languageCode: String? { + Locale.current.languageCode + } + + var regionCode: String? { + Locale.current.regionCode + } + + let isFamilyShareable = false + + var subscriptionPeriod: SubscriptionPeriod? { + guard let unit = subscriptionUnit else { return nil } + return SubscriptionPeriod(value: subscriptionValue, unit: unit) + } + + var introductoryDiscount: StoreProductDiscount? { nil } + + let discounts: [StoreProductDiscount] = [] +} + +// MARK: - SWProduct Init +extension SWProduct { + init(product: TestStoreProduct) { + localizedDescription = "" + localizedTitle = product.v2Product.name ?? "" + price = product.price + priceLocale = product.locale + productIdentifier = product.productIdentifier + isDownloadable = false + downloadContentLengths = [] + contentVersion = "" + downloadContentVersion = "" + isFamilyShareable = product.isFamilyShareable + subscriptionGroupIdentifier = product.subscriptionGroupIdentifier + + if let period = product.subscriptionPeriod { + self.subscriptionPeriod = SWProductSubscriptionPeriod( + period: period, + numberOfPeriods: 1 + ) + } + } +} From 2a16f2a03add9d9afd262115cd94744d73d266c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:00:39 +0100 Subject: [PATCH 3/9] feat: add test mode support to iOS SDK - Add TestModeManager for detecting test mode users via config - Add TestStoreUser model parsed from config testStoreUsers - Add TestModeColdLaunchAlert for showing test mode status on launch - Add TestModePurchaseDrawer for simulating purchases (purchase/abandon/fail) - Add TestModeTransactionHandler to intercept purchase and restore flows - Override StoreKitManager.getProducts to use cached test products - Override TransactionManager.purchase to show test mode drawer - Override TransactionManager.tryToRestore to show entitlement picker - Override DeviceHelper.isSandbox to return true in test mode - Rename V2Product to SuperwallProduct across codebase - Fetch products from /v1/products endpoint on config load when test mode Co-Authored-By: Claude Opus 4.5 --- .../SuperwallKit/Config/ConfigManager.swift | 68 +++++++++- .../SuperwallKit/Config/Models/Config.swift | 3 + .../Config/Models/TestStoreUser.swift | 24 ++++ .../Dependencies/DependencyContainer.swift | 22 ++++ .../Network/Device Helper/DeviceHelper.swift | 16 ++- Sources/SuperwallKit/Network/Endpoint.swift | 4 +- Sources/SuperwallKit/Network/Network.swift | 4 +- .../Network/V2ProductsResponse.swift | 41 +++--- .../StoreProduct/TestStoreProduct.swift | 24 ++-- .../StoreKit/StoreKitManager.swift | 35 ++++++ .../Transactions/TransactionManager.swift | 30 +++++ .../TestMode/TestModeColdLaunchAlert.swift | 47 +++++++ .../TestMode/TestModeManager.swift | 119 ++++++++++++++++++ .../TestMode/TestModeManagerFactory.swift | 12 ++ .../TestMode/TestModePurchaseDrawer.swift | 66 ++++++++++ .../TestMode/TestModeTransactionHandler.swift | 112 +++++++++++++++++ 16 files changed, 588 insertions(+), 39 deletions(-) create mode 100644 Sources/SuperwallKit/Config/Models/TestStoreUser.swift create mode 100644 Sources/SuperwallKit/TestMode/TestModeColdLaunchAlert.swift create mode 100644 Sources/SuperwallKit/TestMode/TestModeManager.swift create mode 100644 Sources/SuperwallKit/TestMode/TestModeManagerFactory.swift create mode 100644 Sources/SuperwallKit/TestMode/TestModePurchaseDrawer.swift create mode 100644 Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index b494042af7..8f9fff48d6 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -51,6 +51,8 @@ class ConfigManager { & AudienceFilterAttributesFactory & ReceiptFactory & DeviceHelperFactory + & TestModeManagerFactory + & HasExternalPurchaseControllerFactory private let factory: Factory init( @@ -394,12 +396,26 @@ class ConfigManager { triggersByPlacementName = ConfigLogic.getTriggersByPlacementName(from: config.triggers) choosePaywallVariants(from: config.triggers) - await factory.loadPurchasedProducts(config: config) + // Evaluate test mode before loading products + let testModeManager = factory.makeTestModeManager() + testModeManager.evaluateTestMode(config: config) + + if testModeManager.isTestMode { + // In test mode, fetch products from API instead of StoreKit + await fetchTestModeProducts(testModeManager: testModeManager) + } else { + await factory.loadPurchasedProducts(config: config) + } + Task { await webEntitlementRedeemer.pollWebEntitlements(config: config, isFirstTime: isFirstTime) } if isFirstTime { await checkForTouchesBeganTrigger(in: config.triggers) + + if testModeManager.isTestMode, let reason = testModeManager.testModeReason { + await presentTestModeColdLaunchAlert(reason: reason) + } } } @@ -619,4 +635,54 @@ class ConfigManager { ) await Superwall.shared.track(preloadComplete) } + + // MARK: - Test Mode + + private func fetchTestModeProducts(testModeManager: TestModeManager) async { + do { + let response = try await network.getSuperwallProducts() + testModeManager.setProducts(response.data) + + // Also populate storeKitManager.productsById with test products + for superwallProduct in response.data { + let testProduct = TestStoreProduct( + superwallProduct: superwallProduct, + entitlements: [] + ) + let storeProduct = StoreProduct(product: testProduct) + storeKitManager.productsById[superwallProduct.identifier] = storeProduct + } + + Logger.debug( + logLevel: .info, + scope: .superwallCore, + message: "Test mode: loaded \(response.data.count) products from API" + ) + } catch { + Logger.debug( + logLevel: .error, + scope: .superwallCore, + message: "Test mode: failed to fetch products", + error: error + ) + } + } + + @MainActor + private func presentTestModeColdLaunchAlert(reason: TestModeReason) { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = windowScene.windows.first?.rootViewController else { + return + } + + let userId = factory.makeTestModeManager().identityManager.userId + let hasPurchaseController = factory.makeHasExternalPurchaseController() + + TestModeColdLaunchAlert.present( + reason: reason, + userId: userId, + hasPurchaseController: hasPurchaseController, + from: rootVC + ) + } } diff --git a/Sources/SuperwallKit/Config/Models/Config.swift b/Sources/SuperwallKit/Config/Models/Config.swift index b694705631..e0a81f53c2 100644 --- a/Sources/SuperwallKit/Config/Models/Config.swift +++ b/Sources/SuperwallKit/Config/Models/Config.swift @@ -28,6 +28,7 @@ struct Config: Codable, Equatable { } } var iosAppId: String? + var testStoreUsers: [TestStoreUser]? struct Web2AppConfig: Codable, Equatable { let entitlementsMaxAge: Seconds @@ -82,6 +83,7 @@ struct Config: Codable, Equatable { case products = "productsV3" case web2appConfig case iosAppId + case testStoreUsers } init(from decoder: Decoder) throws { @@ -97,6 +99,7 @@ struct Config: Codable, Equatable { attribution = try values.decodeIfPresent(Attribution.self, forKey: .attribution) web2appConfig = try values.decodeIfPresent(Web2AppConfig.self, forKey: .web2appConfig) iosAppId = try values.decodeIfPresent(String.self, forKey: .iosAppId) + testStoreUsers = try values.decodeIfPresent([TestStoreUser].self, forKey: .testStoreUsers) let localization = try values.decode(LocalizationConfig.self, forKey: .localization) locales = Set(localization.locales.map { $0.locale }) diff --git a/Sources/SuperwallKit/Config/Models/TestStoreUser.swift b/Sources/SuperwallKit/Config/Models/TestStoreUser.swift new file mode 100644 index 0000000000..b2f05dc281 --- /dev/null +++ b/Sources/SuperwallKit/Config/Models/TestStoreUser.swift @@ -0,0 +1,24 @@ +// +// TestStoreUser.swift +// Superwall +// +// Created by Claude on 2026-01-27. +// + +import Foundation + +/// Identifies a user who should be in test mode. +struct TestStoreUser: Codable, Equatable, Sendable { + /// The type of identifier used to match the user. + let type: TestStoreUserType + + /// The identifier value. + let value: String +} + +/// The type of identifier used for test store user matching. +enum TestStoreUserType: String, Codable, Equatable, Sendable { + case userId + case vendorId + case aliasId +} diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index bcf3351461..254d6d4b0a 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -41,6 +41,7 @@ final class DependencyContainer { var webEntitlementRedeemer: WebEntitlementRedeemer! var deepLinkRouter: DeepLinkRouter! var attributionFetcher: AttributionFetcher! + var testModeManager: TestModeManager! let permissionHandler = PermissionHandler() // swiftlint:enable implicitly_unwrapped_optional let paywallArchiveManager = PaywallArchiveManager() @@ -149,6 +150,17 @@ final class DependencyContainer { } } + testModeManager = TestModeManager( + identityManager: identityManager, + deviceHelper: deviceHelper + ) + + deviceHelper.testModeManager = testModeManager + + Task { + await storeKitManager.setTestModeManager(testModeManager) + } + appSessionManager = AppSessionManager( configManager: configManager, identityManager: identityManager, @@ -206,6 +218,13 @@ extension DependencyContainer: TransactionManagerFactory { } } +// MARK: - TestModeManagerFactory +extension DependencyContainer: TestModeManagerFactory { + func makeTestModeManager() -> TestModeManager { + return testModeManager + } +} + // MARK: - CacheFactory extension DependencyContainer: CacheFactory { func makeCache() -> PaywallViewControllerCache { @@ -230,6 +249,9 @@ extension DependencyContainer: DeviceHelperFactory { } func makeIsSandbox() -> Bool { + if testModeManager.isTestMode { + return true + } return deviceHelper.isSandbox == "true" } diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index 966ad5f04c..c14271a555 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -176,8 +176,18 @@ class DeviceHelper { return Bundle.main.bundleIdentifier ?? "" }() - /// Returns true if built for the simulator or using TestFlight. - let isSandbox: String = { + /// Set after initialization to enable test mode sandbox override. + var testModeManager: TestModeManager? + + /// Returns true if built for the simulator, using TestFlight, or in test mode. + var isSandbox: String { + if testModeManager?.isTestMode == true { + return "true" + } + return Self.detectSandbox() + } + + private static func detectSandbox() -> String { #if targetEnvironment(simulator) return "true" #else @@ -188,7 +198,7 @@ class DeviceHelper { return "\(url.path.contains("sandboxReceipt"))" #endif - }() + } /// The first URL scheme defined in the Info.plist. Assumes there's only one. let urlScheme: String = { diff --git a/Sources/SuperwallKit/Network/Endpoint.swift b/Sources/SuperwallKit/Network/Endpoint.swift index 61b976ea3b..75078619b0 100644 --- a/Sources/SuperwallKit/Network/Endpoint.swift +++ b/Sources/SuperwallKit/Network/Endpoint.swift @@ -396,10 +396,10 @@ extension Endpoint where // MARK: - V2 Products extension Endpoint where Kind == EndpointKinds.Web2App, - Response == V2ProductsResponse { + Response == SuperwallProductsResponse { /// Fetches all products from the subscriptions API. /// The application is inferred from the SDK's public API key. - static func v2Products() -> Self { + static func superwallProducts() -> Self { return Endpoint( components: Components( host: .web2app, diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index 1e0d9963f2..d4ef08efdb 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -366,10 +366,10 @@ class Network { /// The application is inferred from the SDK's public API key. /// /// - Returns: A response containing all products for this application. - func getV2Products() async throws -> V2ProductsResponse { + func getSuperwallProducts() async throws -> SuperwallProductsResponse { do { return try await urlSession.request( - .v2Products(), + .superwallProducts(), data: SuperwallRequestData(factory: factory) ) } catch { diff --git a/Sources/SuperwallKit/Network/V2ProductsResponse.swift b/Sources/SuperwallKit/Network/V2ProductsResponse.swift index b20d31305c..7cafa3fb24 100644 --- a/Sources/SuperwallKit/Network/V2ProductsResponse.swift +++ b/Sources/SuperwallKit/Network/V2ProductsResponse.swift @@ -1,5 +1,5 @@ // -// V2ProductsResponse.swift +// SuperwallProductsResponse.swift // Superwall // // Created by Claude on 2026-01-26. @@ -7,14 +7,14 @@ import Foundation -/// Response from the /v2/products endpoint containing a list of products. -struct V2ProductsResponse: Decodable { +/// Response from the /v1/products endpoint containing a list of products. +struct SuperwallProductsResponse: Decodable { /// The list of products. - let data: [V2Product] + let data: [SuperwallProduct] } -/// A product from the Superwall catalog (v2 API). -public struct V2Product: Decodable, Sendable { +/// A product from the Superwall catalog. +public struct SuperwallProduct: Decodable, Sendable { /// The unique identifier for the product. public let id: Int @@ -31,13 +31,13 @@ public struct V2Product: Decodable, Sendable { public let name: String? /// The platform this product is for. - public let platform: V2ProductPlatform + public let platform: SuperwallProductPlatform /// The price of the product. - public let price: V2ProductPrice? + public let price: SuperwallProductPrice? /// Subscription details if this is a subscription product. - public let subscription: V2ProductSubscription? + public let subscription: SuperwallProductSubscription? /// The entitlement IDs associated with this product. public let entitlements: [Int] @@ -51,16 +51,19 @@ public struct V2Product: Decodable, Sendable { /// Arbitrary metadata associated with the product. public let metadata: [String: AnyCodable]? + /// The storefront country code for pricing (e.g., "USA"). + public let storefront: String? + enum CodingKeys: String, CodingKey { case id, object, application, identifier, name, platform - case price, subscription, entitlements, metadata + case price, subscription, entitlements, metadata, storefront case createdAt = "created_at" case updatedAt = "updated_at" } } /// The platform a product is available on. -public enum V2ProductPlatform: String, Decodable, Sendable { +public enum SuperwallProductPlatform: String, Decodable, Sendable { case ios case android case stripe @@ -68,7 +71,7 @@ public enum V2ProductPlatform: String, Decodable, Sendable { } /// Price information for a product. -public struct V2ProductPrice: Decodable, Sendable { +public struct SuperwallProductPrice: Decodable, Sendable { /// The price amount in cents. public let amount: Int @@ -77,9 +80,9 @@ public struct V2ProductPrice: Decodable, Sendable { } /// Subscription details for a product. -public struct V2ProductSubscription: Decodable, Sendable { +public struct SuperwallProductSubscription: Decodable, Sendable { /// The subscription period unit. - public let period: V2SubscriptionPeriod + public let period: SuperwallSubscriptionPeriod /// The number of periods in each billing cycle. public let periodCount: Int @@ -88,7 +91,7 @@ public struct V2ProductSubscription: Decodable, Sendable { public let trialPeriodDays: Int? /// Introductory offer details, if any. - public let introductoryOffer: V2IntroductoryOffer? + public let introductoryOffer: SuperwallIntroductoryOffer? enum CodingKeys: String, CodingKey { case period @@ -99,7 +102,7 @@ public struct V2ProductSubscription: Decodable, Sendable { } /// The unit of a subscription period. -public enum V2SubscriptionPeriod: String, Decodable, Sendable { +public enum SuperwallSubscriptionPeriod: String, Decodable, Sendable { case day case week case month @@ -107,9 +110,9 @@ public enum V2SubscriptionPeriod: String, Decodable, Sendable { } /// Introductory offer details. -public struct V2IntroductoryOffer: Decodable, Sendable { +public struct SuperwallIntroductoryOffer: Decodable, Sendable { /// The type of introductory offer. - public let type: V2IntroOfferType + public let type: SuperwallIntroOfferType /// The duration of the offer in days. public let durationDays: Int @@ -121,7 +124,7 @@ public struct V2IntroductoryOffer: Decodable, Sendable { } /// The type of introductory offer. -public enum V2IntroOfferType: String, Decodable, Sendable { +public enum SuperwallIntroOfferType: String, Decodable, Sendable { case freeTrial = "free_trial" case payAsYouGo = "pay_as_you_go" case payUpFront = "pay_up_front" diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/TestStoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/TestStoreProduct.swift index 446763f871..e877deff08 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/TestStoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/TestStoreProduct.swift @@ -9,17 +9,17 @@ import Foundation import StoreKit -/// A `StoreProductType` backed by a `V2Product` from the Superwall API. +/// A `StoreProductType` backed by a `SuperwallProduct` from the Superwall API. /// /// Used for test store products that are not fetched from StoreKit. struct TestStoreProduct: StoreProductType { - let v2Product: V2Product + let superwallProduct: SuperwallProduct let entitlements: Set private let priceFormatterProvider = PriceFormatterProvider() private var subscriptionUnit: SubscriptionPeriod.Unit? { - guard let sub = v2Product.subscription else { return nil } + guard let sub = superwallProduct.subscription else { return nil } switch sub.period { case .day: return .day case .week: return .week @@ -29,15 +29,15 @@ struct TestStoreProduct: StoreProductType { } private var subscriptionValue: Int { - v2Product.subscription?.periodCount ?? 0 + superwallProduct.subscription?.periodCount ?? 0 } var productIdentifier: String { - v2Product.identifier + superwallProduct.identifier } var price: Decimal { - guard let amount = v2Product.price?.amount else { return 0 } + guard let amount = superwallProduct.price?.amount else { return 0 } // amount is in cents return Decimal(amount) / 100 } @@ -45,12 +45,12 @@ struct TestStoreProduct: StoreProductType { var localizedPrice: String { let formatter = NumberFormatter() formatter.numberStyle = .currency - formatter.currencyCode = v2Product.price?.currency.uppercased() ?? "USD" + formatter.currencyCode = superwallProduct.price?.currency.uppercased() ?? "USD" return formatter.string(from: NSDecimalNumber(decimal: price)) ?? "$\(price)" } var currencyCode: String? { - v2Product.price?.currency.uppercased() + superwallProduct.price?.currency.uppercased() } var currencySymbol: String? { @@ -237,12 +237,12 @@ struct TestStoreProduct: StoreProductType { // MARK: - Trial var hasFreeTrial: Bool { - guard let trialDays = v2Product.subscription?.trialPeriodDays else { return false } + guard let trialDays = superwallProduct.subscription?.trialPeriodDays else { return false } return trialDays > 0 } var trialPeriodEndDate: Date? { - guard let trialDays = v2Product.subscription?.trialPeriodDays, trialDays > 0 else { + guard let trialDays = superwallProduct.subscription?.trialPeriodDays, trialDays > 0 else { return nil } return Calendar.current.date(byAdding: .day, value: trialDays, to: Date()) @@ -268,7 +268,7 @@ struct TestStoreProduct: StoreProductType { } var trialPeriodDays: Int { - v2Product.subscription?.trialPeriodDays ?? 0 + superwallProduct.subscription?.trialPeriodDays ?? 0 } var trialPeriodDaysString: String { "\(trialPeriodDays)" } @@ -326,7 +326,7 @@ struct TestStoreProduct: StoreProductType { extension SWProduct { init(product: TestStoreProduct) { localizedDescription = "" - localizedTitle = product.v2Product.name ?? "" + localizedTitle = product.superwallProduct.name ?? "" price = product.price priceLocale = product.locale productIdentifier = product.productIdentifier diff --git a/Sources/SuperwallKit/StoreKit/StoreKitManager.swift b/Sources/SuperwallKit/StoreKit/StoreKitManager.swift index 508c9d6f06..080e09fb38 100644 --- a/Sources/SuperwallKit/StoreKit/StoreKitManager.swift +++ b/Sources/SuperwallKit/StoreKit/StoreKitManager.swift @@ -7,6 +7,7 @@ import Combine actor StoreKitManager { /// Retrieves products from storekit. private let productsManager: ProductsManager + var testModeManager: TestModeManager? private(set) var productsById: [String: StoreProduct] = [:] private struct ProductProcessingResult { @@ -19,6 +20,10 @@ actor StoreKitManager { self.productsManager = productsManager } + func setTestModeManager(_ manager: TestModeManager) { + self.testModeManager = manager + } + func getProductVariables(for paywall: Paywall) async -> [ProductVariable] { guard let output = try? await getProducts( forPaywall: paywall, @@ -58,6 +63,36 @@ actor StoreKitManager { productsById: [String: StoreProduct], productItems: [Product] ) { + // In test mode, use cached test products instead of fetching from StoreKit + if testModeManager?.isTestMode == true { + var testProductsById: [String: StoreProduct] = [:] + for (id, product) in productsById { + testProductsById[id] = product + } + + var productItems: [Product] = [] + for original in paywall?.products ?? [] { + if let id = original.id, let product = testProductsById[id] { + productItems.append( + Product( + name: original.name, + type: original.type, + id: id, + entitlements: product.entitlements + ) + ) + } else { + productItems.append(original) + } + } + + testProductsById.forEach { id, product in + self.productsById[id] = product + } + + return (testProductsById, productItems) + } + // 1. Compute fetch IDs = paywall IDs - byProduct IDs + byId IDs let paywallIDs = Set(paywall?.appStoreProductIds ?? []) let byIdIDs: Set = Set(substituteProductsByLabel?.values.compactMap { diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index b870b5d5d2..eeb30ee176 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -25,6 +25,7 @@ final class TransactionManager { & DeviceHelperFactory & HasExternalPurchaseControllerFactory & RestoreAccessFactory + & TestModeManagerFactory enum State { case observing case purchasing(PurchaseSource) @@ -289,6 +290,25 @@ final class TransactionManager { } } + // In test mode, show entitlement picker instead of StoreKit restore + let testModeManager = factory.makeTestModeManager() + if testModeManager.isTestMode { + let handler = TestModeTransactionHandler(testModeManager: testModeManager) + await handler.handleRestore() + + // Set subscription status based on test entitlements + let entitlements = testModeManager.testEntitlementIds.map { + Entitlement(id: String($0)) + } + if entitlements.isEmpty { + Superwall.shared.subscriptionStatus = .inactive + } else { + Superwall.shared.subscriptionStatus = .active(Set(entitlements)) + } + + return entitlements.isEmpty ? .failed() : .restored + } + switch restoreSource { case .internal(let paywallViewController): paywallViewController.loadingState = .loadingPurchase @@ -428,6 +448,16 @@ final class TransactionManager { _ product: StoreProduct, purchaseSource: PurchaseSource ) async -> PurchaseResult { + // In test mode, show the test mode drawer instead of calling StoreKit + let testModeManager = factory.makeTestModeManager() + if testModeManager.isTestMode { + let handler = TestModeTransactionHandler(testModeManager: testModeManager) + return await handler.handlePurchase( + product: product, + purchaseSource: purchaseSource + ) + } + // Attach intro offer token if available from the paywall if case .internal(_, let paywallViewController, _) = purchaseSource { product.introOfferToken = await paywallViewController diff --git a/Sources/SuperwallKit/TestMode/TestModeColdLaunchAlert.swift b/Sources/SuperwallKit/TestMode/TestModeColdLaunchAlert.swift new file mode 100644 index 0000000000..7d8b332444 --- /dev/null +++ b/Sources/SuperwallKit/TestMode/TestModeColdLaunchAlert.swift @@ -0,0 +1,47 @@ +// +// TestModeColdLaunchAlert.swift +// Superwall +// +// Created by Claude on 2026-01-27. +// + +import UIKit + +/// Presents the test mode cold launch alert when a user is first detected as being in test mode. +enum TestModeColdLaunchAlert { + @MainActor + static func present( + reason: TestModeReason, + userId: String, + hasPurchaseController: Bool, + from viewController: UIViewController + ) { + var message = """ + \(reason.description) + + User ID: \(userId) + """ + + if hasPurchaseController { + message += "\n\n⚠️ Purchase controller is not used in Test Mode. All purchases are simulated by Superwall." + } + + message += "\n\nAll purchases will be simulated — no real transactions will occur." + + let alert = UIAlertController( + title: "🧪 Test Mode Active", + message: message, + preferredStyle: .alert + ) + + let copyAction = UIAlertAction(title: "Copy User ID", style: .default) { _ in + UIPasteboard.general.string = userId + } + alert.addAction(copyAction) + + let dismissAction = UIAlertAction(title: "OK", style: .cancel) + alert.addAction(dismissAction) + + viewController.present(alert, animated: true) + } +} diff --git a/Sources/SuperwallKit/TestMode/TestModeManager.swift b/Sources/SuperwallKit/TestMode/TestModeManager.swift new file mode 100644 index 0000000000..495c416e20 --- /dev/null +++ b/Sources/SuperwallKit/TestMode/TestModeManager.swift @@ -0,0 +1,119 @@ +// +// TestModeManager.swift +// Superwall +// +// Created by Claude on 2026-01-27. +// + +import Foundation + +/// The reason why the user is in test mode. +enum TestModeReason: Sendable { + /// The user's alias ID matched a test store user from the config. + case configMatch + + /// The `enableDebugMode` option was explicitly set. + case debugOption + + /// The app's bundle ID doesn't match the config's `bundleIds.ios`. + case bundleIdMismatch(expected: String, actual: String) + + var description: String { + switch self { + case .configMatch: + return "User is in test mode (enabled from dashboard)" + case .debugOption: + return "Debug mode is enabled via SuperwallOptions" + case .bundleIdMismatch(let expected, let actual): + return "Bundle ID mismatch: expected \(expected), got \(actual)" + } + } +} + +/// Manages test mode state for the current user. +/// +/// Test mode allows Superwall to simulate purchases without involving +/// StoreKit or external purchase controllers. When active, purchases are +/// faked and entitlements are set directly. +class TestModeManager { + /// Whether the current user is in test mode. + private(set) var isTestMode: Bool = false + + /// The reason test mode is active, if applicable. + private(set) var testModeReason: TestModeReason? + + /// Products fetched from the `/v1/products` endpoint for test mode use. + private(set) var products: [SuperwallProduct] = [] + + /// Entitlements set via test mode purchases. + private(set) var testEntitlementIds: Set = [] + + unowned let identityManager: IdentityManager + private unowned let deviceHelper: DeviceHelper + + init( + identityManager: IdentityManager, + deviceHelper: DeviceHelper + ) { + self.identityManager = identityManager + self.deviceHelper = deviceHelper + } + + /// Evaluates whether the current user should be in test mode based on the config. + /// Called on every config refresh. + func evaluateTestMode(config: Config) { + let testStoreUsers = config.testStoreUsers ?? [] + + // Check if current user matches any test store user + let aliasId = identityManager.aliasId + let appUserId = identityManager.appUserId + + for testUser in testStoreUsers { + switch testUser.type { + case .userId: + if let appUserId, appUserId == testUser.value { + isTestMode = true + testModeReason = .configMatch + return + } + case .aliasId: + if aliasId == testUser.value { + isTestMode = true + testModeReason = .configMatch + return + } + case .vendorId: + if deviceHelper.vendorId == testUser.value { + isTestMode = true + testModeReason = .configMatch + return + } + } + } + + isTestMode = false + testModeReason = nil + } + + /// Sets the products available for test mode purchases. + func setProducts(_ products: [SuperwallProduct]) { + self.products = products + } + + /// Simulates a purchase by adding the product's entitlements. + func fakePurchase(entitlementIds: [Int]) { + for id in entitlementIds { + testEntitlementIds.insert(id) + } + } + + /// Resets test entitlements (used when restoring in test mode). + func resetEntitlements() { + testEntitlementIds.removeAll() + } + + /// Sets entitlements from an entitlement picker selection. + func setEntitlements(_ entitlementIds: Set) { + testEntitlementIds = entitlementIds + } +} diff --git a/Sources/SuperwallKit/TestMode/TestModeManagerFactory.swift b/Sources/SuperwallKit/TestMode/TestModeManagerFactory.swift new file mode 100644 index 0000000000..9d95615056 --- /dev/null +++ b/Sources/SuperwallKit/TestMode/TestModeManagerFactory.swift @@ -0,0 +1,12 @@ +// +// TestModeManagerFactory.swift +// Superwall +// +// Created by Claude on 2026-01-27. +// + +import Foundation + +protocol TestModeManagerFactory { + func makeTestModeManager() -> TestModeManager +} diff --git a/Sources/SuperwallKit/TestMode/TestModePurchaseDrawer.swift b/Sources/SuperwallKit/TestMode/TestModePurchaseDrawer.swift new file mode 100644 index 0000000000..52b48db253 --- /dev/null +++ b/Sources/SuperwallKit/TestMode/TestModePurchaseDrawer.swift @@ -0,0 +1,66 @@ +// +// TestModePurchaseDrawer.swift +// Superwall +// +// Created by Claude on 2026-01-27. +// + +import UIKit + +/// The result of a test mode purchase interaction. +enum TestModePurchaseResult { + /// User chose to simulate a successful purchase. + case purchased + /// User chose to abandon the purchase. + case abandoned + /// User chose to simulate a purchase failure. + case failed +} + +/// Presents a bottom sheet for test mode purchases instead of calling StoreKit. +/// +/// Shows three options: Purchase, Abandon, Failure — each fires the same +/// events as a real transaction would. +enum TestModePurchaseDrawer { + @MainActor + static func present( + productIdentifier: String, + from viewController: UIViewController, + completion: @escaping (TestModePurchaseResult) -> Void + ) { + let alert = UIAlertController( + title: "🧪 Test Mode Transaction", + message: "This is a test-mode transaction for:\n\(productIdentifier)\n\nNo real charge will occur.", + preferredStyle: .actionSheet + ) + + let purchaseAction = UIAlertAction(title: "✅ Purchase", style: .default) { _ in + completion(.purchased) + } + alert.addAction(purchaseAction) + + let abandonAction = UIAlertAction(title: "🚪 Abandon", style: .default) { _ in + completion(.abandoned) + } + alert.addAction(abandonAction) + + let failAction = UIAlertAction(title: "❌ Failure", style: .destructive) { _ in + completion(.failed) + } + alert.addAction(failAction) + + // iPad requires popover source + if let popover = alert.popoverPresentationController { + popover.sourceView = viewController.view + popover.sourceRect = CGRect( + x: viewController.view.bounds.midX, + y: viewController.view.bounds.maxY, + width: 0, + height: 0 + ) + popover.permittedArrowDirections = .down + } + + viewController.present(alert, animated: true) + } +} diff --git a/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift b/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift new file mode 100644 index 0000000000..4f65376b0c --- /dev/null +++ b/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift @@ -0,0 +1,112 @@ +// +// TestModeTransactionHandler.swift +// Superwall +// +// Created by Claude on 2026-01-27. +// + +import UIKit + +/// Handles purchase and restore flows in test mode. +/// +/// Instead of calling StoreKit or the purchase controller, this presents +/// a UI for the user to choose the simulated outcome (purchase/abandon/fail). +final class TestModeTransactionHandler { + private let testModeManager: TestModeManager + + init(testModeManager: TestModeManager) { + self.testModeManager = testModeManager + } + + /// Handles a purchase in test mode by presenting the drawer and returning a `PurchaseResult`. + @MainActor + func handlePurchase( + product: StoreProduct, + purchaseSource: PurchaseSource + ) async -> PurchaseResult { + guard let viewController = topViewController() else { + return .failed(PurchaseError.productUnavailable) + } + + let result = await withCheckedContinuation { (continuation: CheckedContinuation) in + TestModePurchaseDrawer.present( + productIdentifier: product.productIdentifier, + from: viewController + ) { result in + continuation.resume(returning: result) + } + } + + switch result { + case .purchased: + // Find entitlement IDs for this product from test mode products + let entitlementIds = testModeManager.products + .first { $0.identifier == product.productIdentifier }? + .entitlements ?? [] + testModeManager.fakePurchase(entitlementIds: entitlementIds) + + // Set subscription status + let entitlements = testModeManager.testEntitlementIds.map { + Entitlement(id: String($0)) + } + Superwall.shared.subscriptionStatus = .active(Set(entitlements)) + + return .purchased + case .abandoned: + return .cancelled + case .failed: + return .failed(PurchaseError.productUnavailable) + } + } + + /// Handles a restore in test mode by presenting an entitlement picker. + @MainActor + func handleRestore() async { + guard let viewController = topViewController() else { return } + + await withCheckedContinuation { (continuation: CheckedContinuation) in + let alert = UIAlertController( + title: "Test Mode Restore", + message: "Choose the entitlement status to simulate:", + preferredStyle: .alert + ) + + let activeAction = UIAlertAction(title: "Active Subscription", style: .default) { [weak self] _ in + // Set all product entitlements as active + guard let self else { + continuation.resume() + return + } + let allEntitlementIds = self.testModeManager.products.flatMap { $0.entitlements } + self.testModeManager.fakePurchase(entitlementIds: allEntitlementIds) + continuation.resume() + } + alert.addAction(activeAction) + + let clearAction = UIAlertAction(title: "No Subscription", style: .default) { [weak self] _ in + self?.testModeManager.resetEntitlements() + continuation.resume() + } + alert.addAction(clearAction) + + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) { _ in + continuation.resume() + } + alert.addAction(cancelAction) + + viewController.present(alert, animated: true) + } + } + + @MainActor + private func topViewController() -> UIViewController? { + guard let window = UIApplication.sharedApplication?.windows.first(where: { $0.isKeyWindow }), + var topVC = window.rootViewController else { + return nil + } + while let presented = topVC.presentedViewController { + topVC = presented + } + return topVC + } +} From 1b3b7b08f347967f9606df906418aa1e1ada6beb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:07:09 +0100 Subject: [PATCH 4/9] fix: pass nil to RestorationResult.failed Co-Authored-By: Claude Opus 4.5 --- .../SuperwallKit/StoreKit/Transactions/TransactionManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index eeb30ee176..8b6e1bc9d1 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -306,7 +306,7 @@ final class TransactionManager { Superwall.shared.subscriptionStatus = .active(Set(entitlements)) } - return entitlements.isEmpty ? .failed() : .restored + return entitlements.isEmpty ? .failed(nil) : .restored } switch restoreSource { From d2b54042ffbb9d7c69c8361158d6d8501bb6ea95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:14:27 +0100 Subject: [PATCH 5/9] fix: add StoreProduct init for TestStoreProduct and setter for productsById Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 15 +++++++++++++++ Sources/SuperwallKit/Config/ConfigManager.swift | 4 ++-- .../Products/StoreProduct/StoreProduct.swift | 4 ++++ .../SuperwallKit/StoreKit/StoreKitManager.swift | 4 ++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e49dc9f9be..f414dee131 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,3 +95,18 @@ When bumping the version, update all three files: - **ALWAYS run `swiftlint --fix` on any files that have linting violations.** - **Remember: No trailing whitespace is allowed on any lines.** - **Update CHANGELOG.md for customer-facing changes:** Include new API additions, bug fixes, and crash fixes. Focus on what the change does for developers, not internal implementation details. For example: "Added `setIntegrationAttribute()` method to enable setting individual attribution provider IDs" or "Fixed crash when handling expired subscriptions". + +### Pull Requests + +When creating PRs, always include the checklist from `.github/PULL_REQUEST_TEMPLATE.md`: + +- [ ] All unit tests pass. +- [ ] All UI tests pass. +- [ ] Demo project builds and runs on iOS. +- [ ] Demo project builds and runs on Mac Catalyst. +- [ ] Demo project builds and runs on visionOS. +- [ ] I added/updated tests or detailed why my change isn't tested. +- [ ] I added an entry to the `CHANGELOG.md` for any breaking changes, enhancements, or bug fixes. +- [ ] I have run `swiftlint` in the main directory and fixed any issues. +- [ ] I have updated the SDK documentation as well as the online docs. +- [ ] I have reviewed the [contributing guide](https://github.com/superwall-me/paywall-ios/tree/master/.github/CONTRIBUTING.md) diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 8f9fff48d6..f98fed3b31 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -649,8 +649,8 @@ class ConfigManager { superwallProduct: superwallProduct, entitlements: [] ) - let storeProduct = StoreProduct(product: testProduct) - storeKitManager.productsById[superwallProduct.identifier] = storeProduct + let storeProduct = StoreProduct(testProduct: testProduct) + await storeKitManager.setProduct(storeProduct, forIdentifier: superwallProduct.identifier) } Logger.debug( diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift index 2c4c3174d5..d2bd2a1820 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift @@ -384,6 +384,10 @@ public final class StoreProduct: NSObject, StoreProductType, Sendable { self.init(SK2StoreProduct(sk2Product: sk2Product, entitlements: entitlements)) } + convenience init(testProduct: TestStoreProduct) { + self.init(testProduct) + } + /// Creates a blank StoreProduct with empty/default values. static func blank() -> StoreProduct { return StoreProduct(BlankStoreProduct()) diff --git a/Sources/SuperwallKit/StoreKit/StoreKitManager.swift b/Sources/SuperwallKit/StoreKit/StoreKitManager.swift index 080e09fb38..8a7199aa81 100644 --- a/Sources/SuperwallKit/StoreKit/StoreKitManager.swift +++ b/Sources/SuperwallKit/StoreKit/StoreKitManager.swift @@ -10,6 +10,10 @@ actor StoreKitManager { var testModeManager: TestModeManager? private(set) var productsById: [String: StoreProduct] = [:] + + func setProduct(_ product: StoreProduct, forIdentifier identifier: String) { + productsById[identifier] = product + } private struct ProductProcessingResult { let productIdsToLoad: Set let substituteProductsById: [String: StoreProduct] From 0a5debb1ec9885d381581915e33c1572cec3d492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:15:04 +0100 Subject: [PATCH 6/9] fix: remove optional binding on non-optional Product.id Co-Authored-By: Claude Opus 4.5 --- Sources/SuperwallKit/StoreKit/StoreKitManager.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/StoreKit/StoreKitManager.swift b/Sources/SuperwallKit/StoreKit/StoreKitManager.swift index 8a7199aa81..5f2e1c3d31 100644 --- a/Sources/SuperwallKit/StoreKit/StoreKitManager.swift +++ b/Sources/SuperwallKit/StoreKit/StoreKitManager.swift @@ -76,7 +76,8 @@ actor StoreKitManager { var productItems: [Product] = [] for original in paywall?.products ?? [] { - if let id = original.id, let product = testProductsById[id] { + let id = original.id + if let product = testProductsById[id] { productItems.append( Product( name: original.name, From c3916537947d40121b6679c36c9b378b06829a2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:18:49 +0100 Subject: [PATCH 7/9] fix: use String for testEntitlementIds to match Entitlement.id type Co-Authored-By: Claude Opus 4.5 --- .../StoreKit/Transactions/TransactionManager.swift | 2 +- Sources/SuperwallKit/TestMode/TestModeManager.swift | 6 +++--- .../SuperwallKit/TestMode/TestModeTransactionHandler.swift | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index 8b6e1bc9d1..b777e69e40 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -298,7 +298,7 @@ final class TransactionManager { // Set subscription status based on test entitlements let entitlements = testModeManager.testEntitlementIds.map { - Entitlement(id: String($0)) + Entitlement(id: $0) } if entitlements.isEmpty { Superwall.shared.subscriptionStatus = .inactive diff --git a/Sources/SuperwallKit/TestMode/TestModeManager.swift b/Sources/SuperwallKit/TestMode/TestModeManager.swift index 495c416e20..29c413533e 100644 --- a/Sources/SuperwallKit/TestMode/TestModeManager.swift +++ b/Sources/SuperwallKit/TestMode/TestModeManager.swift @@ -46,7 +46,7 @@ class TestModeManager { private(set) var products: [SuperwallProduct] = [] /// Entitlements set via test mode purchases. - private(set) var testEntitlementIds: Set = [] + private(set) var testEntitlementIds: Set = [] unowned let identityManager: IdentityManager private unowned let deviceHelper: DeviceHelper @@ -103,7 +103,7 @@ class TestModeManager { /// Simulates a purchase by adding the product's entitlements. func fakePurchase(entitlementIds: [Int]) { for id in entitlementIds { - testEntitlementIds.insert(id) + testEntitlementIds.insert(String(id)) } } @@ -113,7 +113,7 @@ class TestModeManager { } /// Sets entitlements from an entitlement picker selection. - func setEntitlements(_ entitlementIds: Set) { + func setEntitlements(_ entitlementIds: Set) { testEntitlementIds = entitlementIds } } diff --git a/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift b/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift index 4f65376b0c..5cbe9c159a 100644 --- a/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift +++ b/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift @@ -47,7 +47,7 @@ final class TestModeTransactionHandler { // Set subscription status let entitlements = testModeManager.testEntitlementIds.map { - Entitlement(id: String($0)) + Entitlement(id: $0) } Superwall.shared.subscriptionStatus = .active(Set(entitlements)) From 1b6dc608c4ed5a73abac31fa41af459595d41375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:38:51 +0100 Subject: [PATCH 8/9] feat: add bundle ID mismatch detection for test mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add bundleId to config endpoint application query - Include bundle_id_config in config JSON response - Decode bundleIdConfig in Swift Config model - Check bundle ID mismatch in evaluateTestMode — activates test mode when app bundle ID doesn't match config Co-Authored-By: Claude Opus 4.5 --- Examples/Advanced/Advanced.xcodeproj/project.pbxproj | 4 ++-- Sources/SuperwallKit/Config/Models/Config.swift | 3 +++ Sources/SuperwallKit/TestMode/TestModeManager.swift | 9 +++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Examples/Advanced/Advanced.xcodeproj/project.pbxproj b/Examples/Advanced/Advanced.xcodeproj/project.pbxproj index c6567daf6a..b786ac9192 100644 --- a/Examples/Advanced/Advanced.xcodeproj/project.pbxproj +++ b/Examples/Advanced/Advanced.xcodeproj/project.pbxproj @@ -416,7 +416,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.superwall.Advanced; + PRODUCT_BUNDLE_IDENTIFIER = com.superwall.Advancedn; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = YES; @@ -450,7 +450,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.superwall.Advanced; + PRODUCT_BUNDLE_IDENTIFIER = com.superwall.Advancedn; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = YES; diff --git a/Sources/SuperwallKit/Config/Models/Config.swift b/Sources/SuperwallKit/Config/Models/Config.swift index e0a81f53c2..204029e524 100644 --- a/Sources/SuperwallKit/Config/Models/Config.swift +++ b/Sources/SuperwallKit/Config/Models/Config.swift @@ -28,6 +28,7 @@ struct Config: Codable, Equatable { } } var iosAppId: String? + var bundleIdConfig: String? var testStoreUsers: [TestStoreUser]? struct Web2AppConfig: Codable, Equatable { @@ -83,6 +84,7 @@ struct Config: Codable, Equatable { case products = "productsV3" case web2appConfig case iosAppId + case bundleIdConfig case testStoreUsers } @@ -99,6 +101,7 @@ struct Config: Codable, Equatable { attribution = try values.decodeIfPresent(Attribution.self, forKey: .attribution) web2appConfig = try values.decodeIfPresent(Web2AppConfig.self, forKey: .web2appConfig) iosAppId = try values.decodeIfPresent(String.self, forKey: .iosAppId) + bundleIdConfig = try values.decodeIfPresent(String.self, forKey: .bundleIdConfig) testStoreUsers = try values.decodeIfPresent([TestStoreUser].self, forKey: .testStoreUsers) let localization = try values.decode(LocalizationConfig.self, forKey: .localization) diff --git a/Sources/SuperwallKit/TestMode/TestModeManager.swift b/Sources/SuperwallKit/TestMode/TestModeManager.swift index 29c413533e..4c009930c3 100644 --- a/Sources/SuperwallKit/TestMode/TestModeManager.swift +++ b/Sources/SuperwallKit/TestMode/TestModeManager.swift @@ -91,6 +91,15 @@ class TestModeManager { } } + // Check bundle ID mismatch + if let expectedBundleId = config.bundleIdConfig, + let actualBundleId = Bundle.main.bundleIdentifier, + expectedBundleId != actualBundleId { + isTestMode = true + testModeReason = .bundleIdMismatch(expected: expectedBundleId, actual: actualBundleId) + return + } + isTestMode = false testModeReason = nil } From 5c1047993512d707dbc51a46507e3b1c0dfab643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:09:39 +0100 Subject: [PATCH 9/9] Update testStoreUsers to testModeUserIds --- Sources/SuperwallKit/Config/Models/Config.swift | 6 +++--- Sources/SuperwallKit/TestMode/TestModeManager.swift | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/SuperwallKit/Config/Models/Config.swift b/Sources/SuperwallKit/Config/Models/Config.swift index 204029e524..f6e08f2217 100644 --- a/Sources/SuperwallKit/Config/Models/Config.swift +++ b/Sources/SuperwallKit/Config/Models/Config.swift @@ -29,7 +29,7 @@ struct Config: Codable, Equatable { } var iosAppId: String? var bundleIdConfig: String? - var testStoreUsers: [TestStoreUser]? + var testModeUserIds: [TestStoreUser]? struct Web2AppConfig: Codable, Equatable { let entitlementsMaxAge: Seconds @@ -85,7 +85,7 @@ struct Config: Codable, Equatable { case web2appConfig case iosAppId case bundleIdConfig - case testStoreUsers + case testModeUserIds = "test_mode_user_ids" } init(from decoder: Decoder) throws { @@ -102,7 +102,7 @@ struct Config: Codable, Equatable { web2appConfig = try values.decodeIfPresent(Web2AppConfig.self, forKey: .web2appConfig) iosAppId = try values.decodeIfPresent(String.self, forKey: .iosAppId) bundleIdConfig = try values.decodeIfPresent(String.self, forKey: .bundleIdConfig) - testStoreUsers = try values.decodeIfPresent([TestStoreUser].self, forKey: .testStoreUsers) + testModeUserIds = try values.decodeIfPresent([TestStoreUser].self, forKey: .testModeUserIds) let localization = try values.decode(LocalizationConfig.self, forKey: .localization) locales = Set(localization.locales.map { $0.locale }) diff --git a/Sources/SuperwallKit/TestMode/TestModeManager.swift b/Sources/SuperwallKit/TestMode/TestModeManager.swift index 4c009930c3..d1f1a48b86 100644 --- a/Sources/SuperwallKit/TestMode/TestModeManager.swift +++ b/Sources/SuperwallKit/TestMode/TestModeManager.swift @@ -62,13 +62,13 @@ class TestModeManager { /// Evaluates whether the current user should be in test mode based on the config. /// Called on every config refresh. func evaluateTestMode(config: Config) { - let testStoreUsers = config.testStoreUsers ?? [] + let testModeUserIds = config.testModeUserIds ?? [] // Check if current user matches any test store user let aliasId = identityManager.aliasId let appUserId = identityManager.appUserId - for testUser in testStoreUsers { + for testUser in testModeUserIds { switch testUser.type { case .userId: if let appUserId, appUserId == testUser.value { @@ -93,8 +93,8 @@ class TestModeManager { // Check bundle ID mismatch if let expectedBundleId = config.bundleIdConfig, - let actualBundleId = Bundle.main.bundleIdentifier, - expectedBundleId != actualBundleId { + let actualBundleId = Bundle.main.bundleIdentifier, + expectedBundleId != actualBundleId { isTestMode = true testModeReason = .bundleIdMismatch(expected: expectedBundleId, actual: actualBundleId) return