From d9f7e50401bde5af01f741fe39fba6675ed098c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:21:56 +0100 Subject: [PATCH 01/18] Add custom store product support for external purchase controllers Introduces a new `.custom` product type that allows developers using an external PurchaseController to purchase products through their own payment system. Product data is fetched from the Superwall API and templated into paywalls. A custom transaction ID is generated before purchase and used as the original transaction identifier in transaction_complete. Co-Authored-By: Claude Opus 4.6 --- .../Dependencies/DependencyContainer.swift | 8 + .../Dependencies/FactoryProtocols.swift | 2 + .../SuperwallKit/Models/Paywall/Paywall.swift | 5 + .../Models/Product/CustomStoreProduct.swift | 68 ++ .../SuperwallKit/Models/Product/Product.swift | 74 +- .../Models/Product/ProductStore.swift | 10 + .../Product/StoreProductAdapterObjc.swift | 8 +- .../Network/V2ProductsResponse.swift | 1 + .../Operators/AddPaywallProducts.swift | 102 ++- .../Paywall/Request/PaywallLogic.swift | 9 + .../Products/StoreProduct/StoreProduct.swift | 14 + .../CustomStoreTransaction.swift | 40 ++ .../StoreTransaction/StorePayment.swift | 6 + .../Transactions/TransactionManager.swift | 33 + SuperwallKit.xcodeproj/project.pbxproj | 12 + .../Paywall/Request/CustomProductTests.swift | 665 ++++++++++++++++++ 16 files changed, 1036 insertions(+), 21 deletions(-) create mode 100644 Sources/SuperwallKit/Models/Product/CustomStoreProduct.swift create mode 100644 Sources/SuperwallKit/StoreKit/Transactions/StoreTransaction/CustomStoreTransaction.swift create mode 100644 Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index 0df3a5b681..125a768743 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -504,6 +504,14 @@ extension DependencyContainer: StoreTransactionFactory { appSessionId: appSessionManager.appSession.id ) } + + func makeStoreTransaction(from transaction: CustomStoreTransaction) async -> StoreTransaction { + return StoreTransaction( + transaction: transaction, + configRequestId: configManager.config?.requestId ?? "", + appSessionId: appSessionManager.appSession.id + ) + } } // MARK: - Options Factory diff --git a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift index 9f8aee4647..c90125e07e 100644 --- a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift +++ b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift @@ -131,6 +131,8 @@ protocol StoreTransactionFactory: AnyObject { @available(iOS 15.0, tvOS 15.0, watchOS 8.0, *) func makeStoreTransaction(from transaction: SK2Transaction) async -> StoreTransaction + + func makeStoreTransaction(from transaction: CustomStoreTransaction) async -> StoreTransaction } protocol OptionsFactory: AnyObject { diff --git a/Sources/SuperwallKit/Models/Paywall/Paywall.swift b/Sources/SuperwallKit/Models/Paywall/Paywall.swift index 3eeb0f14dd..fae0e6ee03 100644 --- a/Sources/SuperwallKit/Models/Paywall/Paywall.swift +++ b/Sources/SuperwallKit/Models/Paywall/Paywall.swift @@ -67,6 +67,11 @@ struct Paywall: Codable { return PaywallLogic.getAppStoreProducts(from: products) } + /// The custom products associated with the paywall. + var customProducts: [Product] { + return PaywallLogic.getCustomProducts(from: products) + } + /// Indicates whether scrolling is enabled on the webview. var isScrollEnabled: Bool diff --git a/Sources/SuperwallKit/Models/Product/CustomStoreProduct.swift b/Sources/SuperwallKit/Models/Product/CustomStoreProduct.swift new file mode 100644 index 0000000000..47e262b6e5 --- /dev/null +++ b/Sources/SuperwallKit/Models/Product/CustomStoreProduct.swift @@ -0,0 +1,68 @@ +// +// CustomStoreProduct.swift +// SuperwallKit +// +// Created by Yusuf Tör on 2026-03-12. +// + +import Foundation + +/// A custom product for use with an external purchase controller. +@objc(SWKCustomStoreProduct) +@objcMembers +public final class CustomStoreProduct: NSObject, Codable, Sendable { + /// The product identifier. + public let id: String + + /// The product's store. + private let store: String + + enum CodingKeys: String, CodingKey { + case id = "productIdentifier" + case store + } + + init( + id: String, + store: String = "CUSTOM" + ) { + self.id = id + self.store = store + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(store, forKey: .store) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + store = try container.decode(String.self, forKey: .store) + if store != "CUSTOM" { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Not a Custom product \(store)" + ) + ) + } + super.init() + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? CustomStoreProduct else { + return false + } + return id == other.id + && store == other.store + } + + public override var hash: Int { + var hasher = Hasher() + hasher.combine(id) + hasher.combine(store) + return hasher.finalize() + } +} diff --git a/Sources/SuperwallKit/Models/Product/Product.swift b/Sources/SuperwallKit/Models/Product/Product.swift index 72c215e3dc..81de74fcf0 100644 --- a/Sources/SuperwallKit/Models/Product/Product.swift +++ b/Sources/SuperwallKit/Models/Product/Product.swift @@ -16,6 +16,7 @@ public final class Product: NSObject, Codable, Sendable { case appStore(AppStoreProduct) case stripe(StripeProduct) case paddle(PaddleProduct) + case custom(CustomStoreProduct) } private enum CodingKeys: String, CodingKey { @@ -61,21 +62,32 @@ public final class Product: NSObject, Codable, Sendable { store: .appStore, appStoreProduct: product, stripeProduct: nil, - paddleProduct: nil + paddleProduct: nil, + customProduct: nil ) case .stripe(let product): objcAdapter = .init( store: .stripe, appStoreProduct: nil, stripeProduct: product, - paddleProduct: nil + paddleProduct: nil, + customProduct: nil ) case .paddle(let product): objcAdapter = .init( store: .paddle, appStoreProduct: nil, stripeProduct: nil, - paddleProduct: product + paddleProduct: product, + customProduct: nil + ) + case .custom(let product): + objcAdapter = .init( + store: .custom, + appStoreProduct: nil, + stripeProduct: nil, + paddleProduct: nil, + customProduct: product ) } } @@ -95,9 +107,12 @@ public final class Product: NSObject, Codable, Sendable { try container.encode(product, forKey: .storeProduct) case .paddle(let product): try container.encode(product, forKey: .storeProduct) + case .custom(let product): + try container.encode(product, forKey: .storeProduct) } } + // swiftlint:disable:next function_body_length public required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) name = try container.decodeIfPresent(String.self, forKey: .name) @@ -114,7 +129,8 @@ public final class Product: NSObject, Codable, Sendable { store: .appStore, appStoreProduct: appStoreProduct, stripeProduct: nil, - paddleProduct: nil + paddleProduct: nil, + customProduct: nil ) // Try to decode from swCompositeProductId, fallback to computing from type if let decodedId = try? container.decode(String.self, forKey: .swCompositeProductId) { @@ -130,7 +146,8 @@ public final class Product: NSObject, Codable, Sendable { store: .stripe, appStoreProduct: nil, stripeProduct: stripeProduct, - paddleProduct: nil + paddleProduct: nil, + customProduct: nil ) // Try to decode from swCompositeProductId, fallback to computing from type if let decodedId = try? container.decode(String.self, forKey: .swCompositeProductId) { @@ -139,19 +156,40 @@ public final class Product: NSObject, Codable, Sendable { id = stripeProduct.id } } catch { - let paddleProduct = try container.decode(PaddleProduct.self, forKey: .storeProduct) - type = .paddle(paddleProduct) - objcAdapter = .init( - store: .paddle, - appStoreProduct: nil, - stripeProduct: nil, - paddleProduct: paddleProduct - ) - // Try to decode from swCompositeProductId, fallback to computing from type - if let decodedId = try? container.decode(String.self, forKey: .swCompositeProductId) { - id = decodedId - } else { - id = paddleProduct.id + do { + let paddleProduct = try container.decode(PaddleProduct.self, forKey: .storeProduct) + type = .paddle(paddleProduct) + objcAdapter = .init( + store: .paddle, + appStoreProduct: nil, + stripeProduct: nil, + paddleProduct: paddleProduct, + customProduct: nil + ) + // Try to decode from swCompositeProductId, fallback to computing from type + if let decodedId = try? container.decode(String.self, forKey: .swCompositeProductId) { + id = decodedId + } else { + id = paddleProduct.id + } + } catch { + let customProduct = try container.decode( + CustomStoreProduct.self, + forKey: .storeProduct + ) + type = .custom(customProduct) + objcAdapter = .init( + store: .custom, + appStoreProduct: nil, + stripeProduct: nil, + paddleProduct: nil, + customProduct: customProduct + ) + if let decodedId = try? container.decode(String.self, forKey: .swCompositeProductId) { + id = decodedId + } else { + id = customProduct.id + } } } } diff --git a/Sources/SuperwallKit/Models/Product/ProductStore.swift b/Sources/SuperwallKit/Models/Product/ProductStore.swift index a7452a8162..1bdb9e86e1 100644 --- a/Sources/SuperwallKit/Models/Product/ProductStore.swift +++ b/Sources/SuperwallKit/Models/Product/ProductStore.swift @@ -25,6 +25,9 @@ public enum ProductStore: Int, Codable, Sendable { /// A manually granted entitlement from the Superwall dashboard. case superwall + /// A custom product for use with an external purchase controller. + case custom + /// Other/Unknown store. case other @@ -41,6 +44,8 @@ public enum ProductStore: Int, Codable, Sendable { return CodingKeys.playStore.rawValue case .superwall: return CodingKeys.superwall.rawValue + case .custom: + return CodingKeys.custom.rawValue case .other: return CodingKeys.other.rawValue } @@ -52,6 +57,7 @@ public enum ProductStore: Int, Codable, Sendable { case paddle = "PADDLE" case playStore = "PLAY_STORE" case superwall = "SUPERWALL" + case custom = "CUSTOM" case other = "OTHER" } @@ -68,6 +74,8 @@ public enum ProductStore: Int, Codable, Sendable { try container.encode(CodingKeys.playStore.rawValue) case .superwall: try container.encode(CodingKeys.superwall.rawValue) + case .custom: + try container.encode(CodingKeys.custom.rawValue) case .other: try container.encode(CodingKeys.other.rawValue) } @@ -88,6 +96,8 @@ public enum ProductStore: Int, Codable, Sendable { self = .playStore case .superwall: self = .superwall + case .custom: + self = .custom case .other: self = .other case .none: diff --git a/Sources/SuperwallKit/Models/Product/StoreProductAdapterObjc.swift b/Sources/SuperwallKit/Models/Product/StoreProductAdapterObjc.swift index 5118d73097..4e4dff6bc5 100644 --- a/Sources/SuperwallKit/Models/Product/StoreProductAdapterObjc.swift +++ b/Sources/SuperwallKit/Models/Product/StoreProductAdapterObjc.swift @@ -26,15 +26,21 @@ public final class StoreProductAdapterObjc: NSObject, Codable, Sendable { /// `paddle`. public let paddleProduct: PaddleProduct? + /// The custom product. This is non-nil if `store` is + /// `custom`. + public let customProduct: CustomStoreProduct? + init( store: ProductStore, appStoreProduct: AppStoreProduct?, stripeProduct: StripeProduct?, - paddleProduct: PaddleProduct? + paddleProduct: PaddleProduct?, + customProduct: CustomStoreProduct? ) { self.store = store self.appStoreProduct = appStoreProduct self.stripeProduct = stripeProduct self.paddleProduct = paddleProduct + self.customProduct = customProduct } } diff --git a/Sources/SuperwallKit/Network/V2ProductsResponse.swift b/Sources/SuperwallKit/Network/V2ProductsResponse.swift index e68209982a..400a8a56ee 100644 --- a/Sources/SuperwallKit/Network/V2ProductsResponse.swift +++ b/Sources/SuperwallKit/Network/V2ProductsResponse.swift @@ -53,6 +53,7 @@ public enum SuperwallProductPlatform: String, Decodable, Sendable { case stripe case paddle case superwall + case custom } /// Price information for a product. diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift index ba8279f19d..63159b1fbc 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift @@ -1,6 +1,7 @@ +// swiftlint:disable file_length // // File.swift -// +// // // Created by Yusuf Tör on 12/05/2023. // @@ -32,6 +33,13 @@ extension PaywallRequestManager { private func getProducts(for paywall: Paywall, request: PaywallRequest) async throws -> Paywall { var paywall = paywall + // Pre-populate custom products from the Superwall API before fetching + // App Store products so they're already cached in productsById. + let customProducts = paywall.customProducts + if !customProducts.isEmpty { + await fetchAndCacheCustomProducts(customProducts) + } + do { let result = try await storeKitManager.getProducts( forPaywall: paywall, @@ -42,9 +50,18 @@ extension PaywallRequestManager { paywall.products = result.productItems + // Merge custom products into productsById so they appear in + // product variables and templating. + var mergedProductsById = result.productsById + for product in customProducts { + if let cached = await storeKitManager.productsById[product.id] { + mergedProductsById[product.id] = cached + } + } + let outcome = PaywallLogic.getProductVariables( productItems: result.productItems, - productsById: result.productsById + productsById: mergedProductsById ) paywall.productVariables = outcome.productVariables @@ -71,6 +88,44 @@ extension PaywallRequestManager { } } + // MARK: - Custom Products + + /// Fetches custom products from the Superwall API and caches them in + /// `storeKitManager.productsById` so they can be used for templating. + private func fetchAndCacheCustomProducts(_ customProducts: [Product]) async { + let customProductIds = Set(customProducts.map { $0.id }) + // Skip if all custom products are already cached. + let cachedIds = await Set(storeKitManager.productsById.keys) + if customProductIds.isSubset(of: cachedIds) { + return + } + + do { + let response = try await network.getSuperwallProducts() + for superwallProduct in response.data where customProductIds.contains(superwallProduct.identifier) { + let entitlements = Set(superwallProduct.entitlements.map { + Entitlement(id: $0.identifier) + }) + let testProduct = TestStoreProduct( + superwallProduct: superwallProduct, + entitlements: entitlements + ) + let storeProduct = StoreProduct(customProduct: testProduct) + await storeKitManager.setProduct( + storeProduct, + forIdentifier: superwallProduct.identifier + ) + } + } catch { + Logger.debug( + logLevel: .error, + scope: .productsManager, + message: "Failed to fetch custom products from API", + error: error + ) + } + } + // MARK: - Analytics private func trackProductsLoadStart(paywall: Paywall, request: PaywallRequest) async -> Paywall { var paywall = paywall @@ -190,6 +245,16 @@ extension PaywallRequestManager { ) } + // Check custom products for trial eligibility using the same entitlement-based + // approach as Stripe products. + if !paywall.isFreeTrialAvailable { + paywall.isFreeTrialAvailable = await checkCustomTrialEligibility( + productItems: paywall.products, + productsById: productsById, + introOfferEligibility: paywall.introOfferEligibility + ) + } + return paywall } @@ -321,4 +386,37 @@ extension PaywallRequestManager { } return false } + + // MARK: - Custom Trial Eligibility + + /// Checks custom products for trial eligibility using the cached StoreProduct data. + private func checkCustomTrialEligibility( + productItems: [Product], + productsById: [String: StoreProduct], + introOfferEligibility: IntroOfferEligibility + ) async -> Bool { + if introOfferEligibility == .ineligible { + return false + } + + for productItem in productItems { + if case .custom = productItem.type { + guard let storeProduct = productsById[productItem.id] else { + continue + } + if storeProduct.hasFreeTrial { + if productItem.entitlements.isEmpty { + continue + } + let hasEntitlement = await hasEverHadEntitlement( + forProductEntitlements: productItem.entitlements + ) + if !hasEntitlement { + return true + } + } + } + } + return false + } } diff --git a/Sources/SuperwallKit/Paywall/Request/PaywallLogic.swift b/Sources/SuperwallKit/Paywall/Request/PaywallLogic.swift index 02a1cc3340..6ea6ac33f6 100644 --- a/Sources/SuperwallKit/Paywall/Request/PaywallLogic.swift +++ b/Sources/SuperwallKit/Paywall/Request/PaywallLogic.swift @@ -46,6 +46,15 @@ enum PaywallLogic { } } + static func getCustomProducts(from products: [Product]) -> [Product] { + return products.filter { + if case .custom = $0.type { + return true + } + return false + } + } + static func handlePaywallError( _ error: Error, forPlacement placement: PlacementData?, diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift index 5b4b76f8e4..e935c82c20 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift @@ -45,6 +45,15 @@ public final class StoreProduct: NSObject, StoreProductType, Sendable { /// ``` public nonisolated(unsafe) var introOfferToken: IntroOfferToken? + /// Whether this product is a custom product backed by the Superwall API. + nonisolated(unsafe) var isCustomProduct = false + + /// A pre-generated transaction ID for custom products. + /// + /// This is set before purchase and used as the `originalTransactionIdentifier` + /// in the resulting `CustomStoreTransaction`. + nonisolated(unsafe) var customTransactionId: String? + /// A `Set` of ``Entitlements`` associated with the product. public var entitlements: Set { product.entitlements @@ -388,6 +397,11 @@ public final class StoreProduct: NSObject, StoreProductType, Sendable { self.init(testProduct) } + convenience init(customProduct: TestStoreProduct) { + self.init(customProduct) + self.isCustomProduct = true + } + /// Creates a blank StoreProduct with empty/default values. static func blank() -> StoreProduct { return StoreProduct(BlankStoreProduct()) diff --git a/Sources/SuperwallKit/StoreKit/Transactions/StoreTransaction/CustomStoreTransaction.swift b/Sources/SuperwallKit/StoreKit/Transactions/StoreTransaction/CustomStoreTransaction.swift new file mode 100644 index 0000000000..7299f1643c --- /dev/null +++ b/Sources/SuperwallKit/StoreKit/Transactions/StoreTransaction/CustomStoreTransaction.swift @@ -0,0 +1,40 @@ +// +// CustomStoreTransaction.swift +// SuperwallKit +// +// Created by Yusuf Tör on 2026-03-12. +// + +import Foundation + +/// A `StoreTransactionType` for custom products purchased through an external +/// purchase controller. The transaction ID is pre-generated before purchase. +struct CustomStoreTransaction: StoreTransactionType { + let transactionDate: Date? + let originalTransactionIdentifier: String + let state: StoreTransactionState + let storeTransactionId: String? + let payment: StorePayment + let originalTransactionDate: Date? + let webOrderLineItemID: String? = nil + let appBundleId: String? = nil + let subscriptionGroupId: String? = nil + let isUpgraded: Bool? = nil + let expirationDate: Date? = nil + let offerId: String? = nil + let revocationDate: Date? = nil + let appAccountToken: UUID? = nil + + init( + customTransactionId: String, + productIdentifier: String, + purchaseDate: Date = Date() + ) { + self.transactionDate = purchaseDate + self.originalTransactionIdentifier = customTransactionId + self.state = .purchased + self.storeTransactionId = customTransactionId + self.payment = StorePayment(productIdentifier: productIdentifier) + self.originalTransactionDate = purchaseDate + } +} diff --git a/Sources/SuperwallKit/StoreKit/Transactions/StoreTransaction/StorePayment.swift b/Sources/SuperwallKit/StoreKit/Transactions/StoreTransaction/StorePayment.swift index fce21cec65..aa8b76772f 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/StoreTransaction/StorePayment.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/StoreTransaction/StorePayment.swift @@ -33,4 +33,10 @@ public final class StorePayment: NSObject, Encodable, Sendable { self.quantity = transaction.purchasedQuantity self.discountIdentifier = nil } + + init(productIdentifier: String) { + self.productIdentifier = productIdentifier + self.quantity = 1 + self.discountIdentifier = nil + } } diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index 8b3313892a..500d71c941 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -614,6 +614,11 @@ final class TransactionManager { product: StoreProduct, purchaseSource: PurchaseSource ) async { + // Generate a custom transaction ID for custom products before purchase. + if product.isCustomProduct, product.customTransactionId == nil { + product.customTransactionId = UUID().uuidString + } + let isFreeTrialAvailable = await receiptManager.isFreeTrialAvailable(for: product) var isObserved = false @@ -698,6 +703,34 @@ final class TransactionManager { return } + // For custom products, create a CustomStoreTransaction instead of + // querying StoreKit. Skip receipt loading since there's no App Store receipt. + if product.isCustomProduct, let customTxnId = product.customTransactionId { + let customTransaction = CustomStoreTransaction( + customTransactionId: customTxnId, + productIdentifier: product.productIdentifier + ) + let transaction = await factory.makeStoreTransaction(from: customTransaction) + await trackTransactionDidSucceed(transaction) + + if case let .internal(_, paywallViewController, shouldDismiss) = source { + let superwallOptions = factory.makeSuperwallOptions() + let shouldDismissPaywall = superwallOptions.paywalls.automaticallyDismiss && shouldDismiss + if shouldDismissPaywall { + await Superwall.shared.dismiss( + paywallViewController, + result: .purchased(product) + ) + } + if !shouldDismissPaywall { + await MainActor.run { + paywallViewController.togglePaywallSpinner(isHidden: true) + } + } + } + return + } + switch source { case let .internal(_, paywallViewController, shouldDismiss): guard let product = await coordinator.product else { diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 42a63d356d..5f817c7d29 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -85,6 +85,7 @@ 234C1753A4606242CA765CA7 /* ManagedTriggerRuleOccurrence.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCFFBE357699F5CAAB803DA7 /* ManagedTriggerRuleOccurrence.swift */; }; 23CD6038DD65F057C81A412D /* PaywallManagerLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6944763A0D07AFA102B023C5 /* PaywallManagerLogicTests.swift */; }; 2428529A6B2B6E873DEC22E8 /* Assignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA9100DDAD2E8596F96A1BCB /* Assignment.swift */; }; + 2517FC60F3A7288C5FE34A73 /* CustomProductTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD32AF04F6FB9601759E529 /* CustomProductTests.swift */; }; 252D37DDAA2C97A6E2DDD6B7 /* SurveyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 655E5AE73EF5723A28D2EADD /* SurveyTests.swift */; }; 25E2A4570B63FE36E4DD4E52 /* TemplateLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E541F079BC78206BC44D6E /* TemplateLogic.swift */; }; 26237FCC56AE2B7B68C9F1B1 /* SWWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C2580C3CD6A8BF0C5258665 /* SWWebView.swift */; }; @@ -268,6 +269,7 @@ 822B2898CDD9C6E50816F62B /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9298A79020030E9A1357A6 /* API.swift */; }; 84616856D40F775122FD9BF9 /* Dictionary+Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 571825E7515FCC1E877D4429 /* Dictionary+Cache.swift */; }; 847E0BD4BDA515E47608F6A1 /* ProductsFetcherSK2Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ECD75DF8F3EB6A68A21444D /* ProductsFetcherSK2Tests.swift */; }; + 8537CA38FFD40CF7C8A6A691 /* CustomStoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = C66CFEB3004DF2C3C3DB44FF /* CustomStoreProduct.swift */; }; 85728EABBC5C73193AC5F876 /* CustomURLSessionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3506FCC35155DF104A1DFCA /* CustomURLSessionMock.swift */; }; 8583971F8E9E51E9B7A4FCC6 /* PurchasingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB8384E2DB0A3627BE1CCB7D /* PurchasingCoordinator.swift */; }; 880BBB2099D3112F256E6AE2 /* IntroOfferEligibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93FACE677755EAA3EA4E67A8 /* IntroOfferEligibility.swift */; }; @@ -459,6 +461,7 @@ D7F5A91A1E37E6BFB84E5609 /* StoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10310294FD27EB6A0621341 /* StoreProduct.swift */; }; D89E9C69317044050B97B573 /* IdentityManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2714D9FD9F55611B4C5E4E7D /* IdentityManagerMock.swift */; }; D89F615A7826947C9246F6B1 /* WebArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = E72593E1D4123B176EC83499 /* WebArchive.swift */; }; + D90B2915CA23976F48794449 /* CustomStoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0704117EF9F98A047714162B /* CustomStoreTransaction.swift */; }; D916475C6CE464EEB094F419 /* TaskRetryLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7867A3C9B173BC2D6000937 /* TaskRetryLogic.swift */; }; D91750797BB4947F6975B2B9 /* Date+IsoStringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C7673988B39FB0BDEA8BE4 /* Date+IsoStringTests.swift */; }; D978EAD4FA4865B5E07BF03B /* Future+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DE36D141F461F6E945823FA /* Future+Async.swift */; }; @@ -581,6 +584,7 @@ 054FCADFEF560A1A736DEFE4 /* StoreKitManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitManagerTests.swift; sourceTree = ""; }; 0596DAAE31B2242A59060C5F /* StorePresentationObjects.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorePresentationObjects.swift; sourceTree = ""; }; 0695B39826F85AACBA833B77 /* InAppReceipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppReceipt.swift; sourceTree = ""; }; + 0704117EF9F98A047714162B /* CustomStoreTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomStoreTransaction.swift; sourceTree = ""; }; 072886BB8C0E08DF414D9162 /* InAppReceiptPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppReceiptPayload.swift; sourceTree = ""; }; 07FF7BCB3FA673AAEC8F9154 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 08AEAA8E3B5F51848523AE61 /* IntroOfferEligibilityRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroOfferEligibilityRequest.swift; sourceTree = ""; }; @@ -626,6 +630,7 @@ 1CC92F1146FA9FA76AF25227 /* TrackingAuthorizationStatusConversionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackingAuthorizationStatusConversionTests.swift; sourceTree = ""; }; 1D275ED98D2EE298F06708AF /* UIWindow+SwizzleSendEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindow+SwizzleSendEvent.swift"; sourceTree = ""; }; 1EBE35B7BB7FEBE02C8992D8 /* EntitlementsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementsResponse.swift; sourceTree = ""; }; + 1FD32AF04F6FB9601759E529 /* CustomProductTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProductTests.swift; sourceTree = ""; }; 2031E7FE7D2ECC7AFF8519AE /* CustomerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerInfo.swift; sourceTree = ""; }; 20365697A9C396E8EC746B77 /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = ""; }; 20419CBCAD8CE28ADDD55E57 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; @@ -1019,6 +1024,7 @@ C567965E23812D8C25869C19 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; C5B5BF873B8D190097E8CFB5 /* PriceFormatterProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceFormatterProvider.swift; sourceTree = ""; }; C65CEB049E29538C699F6EF8 /* PaywallPresentationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationInfo.swift; sourceTree = ""; }; + C66CFEB3004DF2C3C3DB44FF /* CustomStoreProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomStoreProduct.swift; sourceTree = ""; }; C6BB83F17D20143827C28042 /* EntitlementsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementsInfo.swift; sourceTree = ""; }; C733A9BE56EA9E10D75B073B /* SWDebugManagerLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWDebugManagerLogicTests.swift; sourceTree = ""; }; C7867A3C9B173BC2D6000937 /* TaskRetryLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRetryLogic.swift; sourceTree = ""; }; @@ -1685,6 +1691,7 @@ 41C20E3AA2F12F64126C7D72 /* StoreTransaction */ = { isa = PBXGroup; children = ( + 0704117EF9F98A047714162B /* CustomStoreTransaction.swift */, 84D6F69BA2B44540FAF05E36 /* SK1StoreTransaction.swift */, C3B96E2A1A289D96267EC0BC /* SK2StoreTransaction.swift */, F6EED7C7E264C38A1A7C3EFB /* StorePayment.swift */, @@ -2149,6 +2156,7 @@ isa = PBXGroup; children = ( 18E059F7745769ABCA0F2A99 /* AppStoreProduct.swift */, + C66CFEB3004DF2C3C3DB44FF /* CustomStoreProduct.swift */, 8F17CFCD6B3B96A609A5B870 /* PaddleProduct.swift */, D38D3A69A26709BFB52C6A3A /* Product.swift */, 7106327DAD1C9044E4A57DD5 /* ProductStore.swift */, @@ -2670,6 +2678,7 @@ C800729D322B65BAD6FB3668 /* Request */ = { isa = PBXGroup; children = ( + 1FD32AF04F6FB9601759E529 /* CustomProductTests.swift */, 8A7140A8B0B006F2080D8915 /* PaywallLogicTests.swift */, EA72EEA12B281C235816CA44 /* StripeTrialEligibilityTests.swift */, C5DBF410102721FC5D440DF8 /* Mocks */, @@ -3168,6 +3177,7 @@ A1621A749D8F05959A486ACE /* CoreDataManagerMock.swift in Sources */, C7AB21123540550E513AD28A /* CoreDataManagerTests.swift in Sources */, ABC17AE96AD396607E3CAB17 /* CoreDataStackMock.swift in Sources */, + 2517FC60F3A7288C5FE34A73 /* CustomProductTests.swift in Sources */, 85728EABBC5C73193AC5F876 /* CustomURLSessionMock.swift in Sources */, 37FDB46DD55E649FA10D753C /* CustomerInfoDecodingTests.swift in Sources */, 654803E77F7CDBF6282D0110 /* Date+IsWithinAnHourBeforeTests.swift in Sources */, @@ -3329,6 +3339,8 @@ 41EAF9340665BF7459B2C29C /* CoreDataStack.swift in Sources */, 44E2AE9B0AED16C48027CD21 /* CustomCallback.swift in Sources */, 42B707D898A49DCB2B33837C /* CustomCallbackRegistry.swift in Sources */, + 8537CA38FFD40CF7C8A6A691 /* CustomStoreProduct.swift in Sources */, + D90B2915CA23976F48794449 /* CustomStoreTransaction.swift in Sources */, 9E21D97817B1BA97806283B3 /* CustomURLSession.swift in Sources */, 8E5661E20F318661BB005E2F /* CustomerInfo.swift in Sources */, E7FD108C357A816AF8BFBA47 /* DarkBlurredBackground.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift b/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift new file mode 100644 index 0000000000..738f1d5dd6 --- /dev/null +++ b/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift @@ -0,0 +1,665 @@ +// +// CustomProductTests.swift +// SuperwallKitTests +// +// Created by Yusuf Tör on 2026-03-12. +// +// swiftlint:disable all + +import Foundation +import Testing +@testable import SuperwallKit + +/// Tests for custom product model decoding, StoreProduct integration, +/// custom transaction creation, and trial eligibility. +struct CustomProductTests { + + // MARK: - CustomStoreProduct Decoding + + @Test + func customStoreProduct_decodesFromJSON() throws { + let json = """ + { + "productIdentifier": "custom_prod_123", + "store": "CUSTOM" + } + """ + let data = json.data(using: .utf8)! + let product = try JSONDecoder().decode(CustomStoreProduct.self, from: data) + #expect(product.id == "custom_prod_123") + } + + @Test + func customStoreProduct_failsDecodingNonCustomStore() { + let json = """ + { + "productIdentifier": "some_prod", + "store": "APP_STORE" + } + """ + let data = json.data(using: .utf8)! + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(CustomStoreProduct.self, from: data) + } + } + + @Test + func customStoreProduct_encodesRoundTrip() throws { + let product = CustomStoreProduct(id: "custom_prod_456") + let data = try JSONEncoder().encode(product) + let decoded = try JSONDecoder().decode(CustomStoreProduct.self, from: data) + #expect(decoded.id == "custom_prod_456") + #expect(decoded == product) + } + + @Test + func customStoreProduct_equality() { + let product1 = CustomStoreProduct(id: "prod_1") + let product2 = CustomStoreProduct(id: "prod_1") + let product3 = CustomStoreProduct(id: "prod_2") + + #expect(product1 == product2) + #expect(product1 != product3) + } + + @Test + func customStoreProduct_hashEquality() { + let product1 = CustomStoreProduct(id: "prod_1") + let product2 = CustomStoreProduct(id: "prod_1") + + #expect(product1.hash == product2.hash) + } + + // MARK: - Product with .custom type + + @Test + func product_customType_decodesFromJSON() throws { + let json = """ + { + "referenceName": "primary", + "storeProduct": { + "productIdentifier": "custom_prod_abc", + "store": "CUSTOM" + }, + "swCompositeProductId": "custom_prod_abc", + "entitlements": [ + {"identifier": "premium", "type": "SERVICE_LEVEL"} + ] + } + """ + let data = json.data(using: .utf8)! + let product = try JSONDecoder().decode(Product.self, from: data) + + #expect(product.name == "primary") + #expect(product.id == "custom_prod_abc") + + if case .custom(let customProduct) = product.type { + #expect(customProduct.id == "custom_prod_abc") + } else { + Issue.record("Expected .custom type but got \(product.type)") + } + + #expect(product.entitlements.count == 1) + #expect(product.entitlements.first?.id == "premium") + } + + @Test + func product_customType_encodesRoundTrip() throws { + let product = Product( + name: "primary", + type: .custom(.init(id: "custom_abc")), + id: "custom_abc", + entitlements: [Entitlement(id: "premium", type: .serviceLevel, isActive: false)] + ) + + let data = try JSONEncoder().encode(product) + let decoded = try JSONDecoder().decode(Product.self, from: data) + + #expect(decoded.name == "primary") + #expect(decoded.id == "custom_abc") + if case .custom = decoded.type {} else { + Issue.record("Expected .custom type after round trip") + } + } + + // MARK: - ProductStore .custom + + @Test + func productStore_customCase() throws { + let json = "\"CUSTOM\"" + let data = json.data(using: .utf8)! + let store = try JSONDecoder().decode(ProductStore.self, from: data) + #expect(store == .custom) + #expect(store.description == "custom") + } + + // MARK: - PaywallLogic.getCustomProducts + + @Test + func getCustomProducts_filtersCustomOnly() { + let customProduct = Product( + name: "custom1", + type: .custom(.init(id: "custom_1")), + id: "custom_1", + entitlements: [] + ) + let appStoreProduct = Product( + name: "primary", + type: .appStore(.init(id: "app_1")), + id: "app_1", + entitlements: [] + ) + let stripeProduct = Product( + name: "stripe1", + type: .stripe(.init(id: "stripe_1", trialDays: nil)), + id: "stripe_1", + entitlements: [] + ) + + let result = PaywallLogic.getCustomProducts(from: [customProduct, appStoreProduct, stripeProduct]) + + #expect(result.count == 1) + #expect(result.first?.id == "custom_1") + } + + @Test + func getCustomProducts_emptyWhenNoCustom() { + let appStoreProduct = Product( + name: "primary", + type: .appStore(.init(id: "app_1")), + id: "app_1", + entitlements: [] + ) + + let result = PaywallLogic.getCustomProducts(from: [appStoreProduct]) + #expect(result.isEmpty) + } + + // MARK: - StoreProduct custom init + + @Test + func storeProduct_customInit_setsIsCustomProduct() { + let superwallProduct = SuperwallProduct( + object: "product", + identifier: "custom_prod_1", + platform: .custom, + price: SuperwallProductPrice(amount: 999, currency: "USD"), + subscription: SuperwallProductSubscription( + period: .month, + periodCount: 1, + trialPeriodDays: 7 + ), + entitlements: [], + storefront: "USA" + ) + let testProduct = TestStoreProduct( + superwallProduct: superwallProduct, + entitlements: [] + ) + let storeProduct = StoreProduct(customProduct: testProduct) + + #expect(storeProduct.isCustomProduct) + #expect(storeProduct.customTransactionId == nil) + #expect(storeProduct.productIdentifier == "custom_prod_1") + } + + @Test + func storeProduct_testInit_doesNotSetCustomFlag() { + let superwallProduct = SuperwallProduct( + object: "product", + identifier: "test_prod_1", + platform: .ios, + price: SuperwallProductPrice(amount: 999, currency: "USD"), + subscription: nil, + entitlements: [], + storefront: "USA" + ) + let testProduct = TestStoreProduct( + superwallProduct: superwallProduct, + entitlements: [] + ) + let storeProduct = StoreProduct(testProduct: testProduct) + + #expect(!storeProduct.isCustomProduct) + } + + // MARK: - TestStoreProduct attribute computation + + @Test + func testStoreProduct_computesPrice() { + let superwallProduct = SuperwallProduct( + object: "product", + identifier: "custom_1", + platform: .custom, + price: SuperwallProductPrice(amount: 1999, currency: "USD"), + subscription: SuperwallProductSubscription( + period: .month, + periodCount: 1, + trialPeriodDays: nil + ), + entitlements: [], + storefront: "USA" + ) + let testProduct = TestStoreProduct( + superwallProduct: superwallProduct, + entitlements: [] + ) + + #expect(testProduct.price == Decimal(1999) / 100) + #expect(testProduct.productIdentifier == "custom_1") + #expect(!testProduct.hasFreeTrial) + } + + @Test + func testStoreProduct_computesTrialInfo() { + let superwallProduct = SuperwallProduct( + object: "product", + identifier: "custom_2", + platform: .custom, + price: SuperwallProductPrice(amount: 499, currency: "EUR"), + subscription: SuperwallProductSubscription( + period: .year, + periodCount: 1, + trialPeriodDays: 14 + ), + entitlements: [], + storefront: "USA" + ) + let testProduct = TestStoreProduct( + superwallProduct: superwallProduct, + entitlements: [] + ) + + #expect(testProduct.hasFreeTrial) + #expect(testProduct.trialPeriodDays == 14) + #expect(testProduct.trialPeriodWeeks == 2) + #expect(testProduct.period == "year") + #expect(testProduct.periodDays == 365) + #expect(testProduct.currencyCode == "EUR") + } + + @Test + func testStoreProduct_noSubscription() { + let superwallProduct = SuperwallProduct( + object: "product", + identifier: "custom_otp", + platform: .custom, + price: SuperwallProductPrice(amount: 2499, currency: "USD"), + subscription: nil, + entitlements: [], + storefront: "USA" + ) + let testProduct = TestStoreProduct( + superwallProduct: superwallProduct, + entitlements: [] + ) + + #expect(!testProduct.hasFreeTrial) + #expect(testProduct.trialPeriodDays == 0) + #expect(testProduct.period == "") + #expect(testProduct.periodDays == 0) + #expect(testProduct.subscriptionPeriod == nil) + } + + // MARK: - CustomStoreTransaction + + @Test + func customStoreTransaction_properties() { + let txnId = "custom-txn-123" + let productId = "custom_prod_1" + let purchaseDate = Date() + + let transaction = CustomStoreTransaction( + customTransactionId: txnId, + productIdentifier: productId, + purchaseDate: purchaseDate + ) + + #expect(transaction.originalTransactionIdentifier == txnId) + #expect(transaction.storeTransactionId == txnId) + #expect(transaction.state == .purchased) + #expect(transaction.transactionDate == purchaseDate) + #expect(transaction.originalTransactionDate == purchaseDate) + #expect(transaction.payment.productIdentifier == productId) + #expect(transaction.payment.quantity == 1) + #expect(transaction.payment.discountIdentifier == nil) + + // SK2-specific properties should be nil + #expect(transaction.webOrderLineItemID == nil) + #expect(transaction.appBundleId == nil) + #expect(transaction.subscriptionGroupId == nil) + #expect(transaction.isUpgraded == nil) + #expect(transaction.expirationDate == nil) + #expect(transaction.offerId == nil) + #expect(transaction.revocationDate == nil) + #expect(transaction.appAccountToken == nil) + } + + @Test + func customStoreTransaction_defaultPurchaseDate() { + let before = Date() + let transaction = CustomStoreTransaction( + customTransactionId: "txn-1", + productIdentifier: "prod-1" + ) + let after = Date() + + #expect(transaction.transactionDate! >= before) + #expect(transaction.transactionDate! <= after) + } + + // MARK: - Custom Trial Eligibility + + /// Replicates `hasEverHadEntitlement` logic from `AddPaywallProducts`. + private static func hasEverHadEntitlement( + forProductEntitlements productEntitlements: Set, + userEntitlements: [Entitlement] + ) -> Bool { + let productEntitlementIds = Set(productEntitlements.map { $0.id }) + if productEntitlementIds.isEmpty { + return false + } + let userEntitlementIds = Set( + userEntitlements + .filter { $0.latestProductId != nil || $0.store == .superwall || $0.isActive } + .map { $0.id } + ) + return !productEntitlementIds.isDisjoint(with: userEntitlementIds) + } + + /// Simulates `checkCustomTrialEligibility` from `AddPaywallProducts`. + private func checkCustomTrialEligibility( + productItems: [SuperwallKit.Product], + productsById: [String: StoreProduct], + introOfferEligibility: IntroOfferEligibility, + userEntitlements: [Entitlement] + ) -> Bool { + if introOfferEligibility == .ineligible { + return false + } + + for productItem in productItems { + if case .custom = productItem.type { + guard let storeProduct = productsById[productItem.id] else { + continue + } + if storeProduct.hasFreeTrial { + if productItem.entitlements.isEmpty { + continue + } + let hasEntitlement = Self.hasEverHadEntitlement( + forProductEntitlements: productItem.entitlements, + userEntitlements: userEntitlements + ) + if !hasEntitlement { + return true + } + } + } + } + return false + } + + /// Helper to create a custom product item. + private func makeCustomProductItem( + id: String = "custom_prod_1", + name: String = "primary", + entitlements: Set = [] + ) -> SuperwallKit.Product { + return SuperwallKit.Product( + name: name, + type: .custom(.init(id: id)), + id: id, + entitlements: entitlements + ) + } + + /// Helper to create a StoreProduct backed by a TestStoreProduct with trial. + private func makeCustomStoreProduct( + id: String = "custom_prod_1", + trialDays: Int? = nil + ) -> StoreProduct { + let superwallProduct = SuperwallProduct( + object: "product", + identifier: id, + platform: .custom, + price: SuperwallProductPrice(amount: 999, currency: "USD"), + subscription: SuperwallProductSubscription( + period: .month, + periodCount: 1, + trialPeriodDays: trialDays + ), + entitlements: [], + storefront: "USA" + ) + let testProduct = TestStoreProduct( + superwallProduct: superwallProduct, + entitlements: [] + ) + return StoreProduct(customProduct: testProduct) + } + + @Test + func customTrialEligibility_hasTrialNoEntitlementHistory_eligible() { + let premiumEntitlement = Entitlement( + id: "premium", + type: .serviceLevel, + isActive: false + ) + let productItem = makeCustomProductItem( + entitlements: [premiumEntitlement] + ) + let storeProduct = makeCustomStoreProduct(trialDays: 7) + + let result = checkCustomTrialEligibility( + productItems: [productItem], + productsById: [productItem.id: storeProduct], + introOfferEligibility: .eligible, + userEntitlements: [] + ) + + #expect(result) + } + + @Test + func customTrialEligibility_hasTrialWithEntitlementHistory_notEligible() { + let premiumEntitlement = Entitlement( + id: "premium", + type: .serviceLevel, + isActive: false + ) + let productItem = makeCustomProductItem( + entitlements: [premiumEntitlement] + ) + let storeProduct = makeCustomStoreProduct(trialDays: 7) + + let userEntitlement = Entitlement( + id: "premium", + type: .serviceLevel, + isActive: true, + latestProductId: "custom_prod_1", + store: .custom + ) + + let result = checkCustomTrialEligibility( + productItems: [productItem], + productsById: [productItem.id: storeProduct], + introOfferEligibility: .eligible, + userEntitlements: [userEntitlement] + ) + + #expect(!result) + } + + @Test + func customTrialEligibility_noTrialDays_notEligible() { + let premiumEntitlement = Entitlement( + id: "premium", + type: .serviceLevel, + isActive: false + ) + let productItem = makeCustomProductItem( + entitlements: [premiumEntitlement] + ) + let storeProduct = makeCustomStoreProduct(trialDays: nil) + + let result = checkCustomTrialEligibility( + productItems: [productItem], + productsById: [productItem.id: storeProduct], + introOfferEligibility: .eligible, + userEntitlements: [] + ) + + #expect(!result) + } + + @Test + func customTrialEligibility_ineligibleMode_notEligible() { + let premiumEntitlement = Entitlement( + id: "premium", + type: .serviceLevel, + isActive: false + ) + let productItem = makeCustomProductItem( + entitlements: [premiumEntitlement] + ) + let storeProduct = makeCustomStoreProduct(trialDays: 7) + + let result = checkCustomTrialEligibility( + productItems: [productItem], + productsById: [productItem.id: storeProduct], + introOfferEligibility: .ineligible, + userEntitlements: [] + ) + + #expect(!result) + } + + @Test + func customTrialEligibility_noEntitlementsConfigured_skipsProduct() { + let productItem = makeCustomProductItem(entitlements: []) + let storeProduct = makeCustomStoreProduct(trialDays: 7) + + let result = checkCustomTrialEligibility( + productItems: [productItem], + productsById: [productItem.id: storeProduct], + introOfferEligibility: .eligible, + userEntitlements: [] + ) + + #expect(!result) + } + + @Test + func customTrialEligibility_notInProductsById_skipsProduct() { + let premiumEntitlement = Entitlement( + id: "premium", + type: .serviceLevel, + isActive: false + ) + let productItem = makeCustomProductItem( + entitlements: [premiumEntitlement] + ) + + // No matching product in productsById + let result = checkCustomTrialEligibility( + productItems: [productItem], + productsById: [:], + introOfferEligibility: .eligible, + userEntitlements: [] + ) + + #expect(!result) + } + + @Test + func customTrialEligibility_configPlaceholderEntitlement_eligible() { + let premiumEntitlement = Entitlement( + id: "premium", + type: .serviceLevel, + isActive: false + ) + let productItem = makeCustomProductItem( + entitlements: [premiumEntitlement] + ) + let storeProduct = makeCustomStoreProduct(trialDays: 7) + + // Config-only placeholder: no latestProductId, no store, not active + let placeholderEntitlement = Entitlement( + id: "premium", + type: .serviceLevel, + isActive: false, + latestProductId: nil, + store: nil + ) + + let result = checkCustomTrialEligibility( + productItems: [productItem], + productsById: [productItem.id: storeProduct], + introOfferEligibility: .eligible, + userEntitlements: [placeholderEntitlement] + ) + + #expect(result) + } + + // MARK: - getProductVariables with custom products + + @Test + func getProductVariables_includesCustomProduct() { + let productId = "custom_prod_1" + let products = [Product( + name: "primary", + type: .custom(.init(id: productId)), + id: productId, + entitlements: [] + )] + + let superwallProduct = SuperwallProduct( + object: "product", + identifier: productId, + platform: .custom, + price: SuperwallProductPrice(amount: 999, currency: "USD"), + subscription: SuperwallProductSubscription( + period: .month, + periodCount: 1, + trialPeriodDays: nil + ), + entitlements: [], + storefront: "USA" + ) + let testProduct = TestStoreProduct( + superwallProduct: superwallProduct, + entitlements: [] + ) + let storeProduct = StoreProduct(customProduct: testProduct) + let productsById = [productId: storeProduct] + + let response = PaywallLogic.getProductVariables( + productItems: products, + productsById: productsById + ) + + #expect(response.productVariables.count == 1) + #expect(response.productVariables.first?.name == "primary") + #expect(response.productVariables.first?.id == productId) + } + + @Test + func getProductVariables_customProductNotInCache_skipped() { + let productId = "custom_prod_1" + let products = [Product( + name: "primary", + type: .custom(.init(id: productId)), + id: productId, + entitlements: [] + )] + + let response = PaywallLogic.getProductVariables( + productItems: products, + productsById: [:] + ) + + #expect(response.productVariables.isEmpty) + } +} From 4bfce3bd92b2e9a4bcbb04f3ba73369d460cecb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:05:17 +0100 Subject: [PATCH 02/18] Always regenerate custom transaction ID on each purchase attempt Fixes a bug where cancelling and retrying a purchase would reuse the same transaction ID, causing potential duplicate-ID collisions in analytics. Co-Authored-By: Claude Opus 4.6 --- .../StoreKit/Transactions/TransactionManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index 500d71c941..beaf567c28 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -614,8 +614,8 @@ final class TransactionManager { product: StoreProduct, purchaseSource: PurchaseSource ) async { - // Generate a custom transaction ID for custom products before purchase. - if product.isCustomProduct, product.customTransactionId == nil { + // Always regenerate the custom transaction ID before a new purchase attempt. + if product.isCustomProduct { product.customTransactionId = UUID().uuidString } From fba87e849369111c63d0b18607ad471fe61f18cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:09:41 +0100 Subject: [PATCH 03/18] Move .custom case after .other in ProductStore to preserve raw values Avoids shifting .other's implicit Int raw value from 5 to 6, which could break external consumers persisting rawValue or using ObjC bridged constants. Co-Authored-By: Claude Opus 4.6 --- Sources/SuperwallKit/Models/Product/ProductStore.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/Models/Product/ProductStore.swift b/Sources/SuperwallKit/Models/Product/ProductStore.swift index 1bdb9e86e1..fe4f1ba1d2 100644 --- a/Sources/SuperwallKit/Models/Product/ProductStore.swift +++ b/Sources/SuperwallKit/Models/Product/ProductStore.swift @@ -25,12 +25,12 @@ public enum ProductStore: Int, Codable, Sendable { /// A manually granted entitlement from the Superwall dashboard. case superwall - /// A custom product for use with an external purchase controller. - case custom - /// Other/Unknown store. case other + /// A custom product for use with an external purchase controller. + case custom + /// Returns the string representation of the product store (e.g., "APP_STORE", "STRIPE") public var description: String { switch self { From 9bcf13a1144aadb7ca638f782dc2faf8ce948eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:58:32 +0100 Subject: [PATCH 04/18] Fix tests --- .../StoreKit/Transactions/TransactionManager.swift | 3 ++- .../SuperwallKitTests/Paywall/Request/CustomProductTests.swift | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index beaf567c28..dc1cf97855 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -705,7 +705,8 @@ final class TransactionManager { // For custom products, create a CustomStoreTransaction instead of // querying StoreKit. Skip receipt loading since there's no App Store receipt. - if product.isCustomProduct, let customTxnId = product.customTransactionId { + if product.isCustomProduct, + let customTxnId = product.customTransactionId { let customTransaction = CustomStoreTransaction( customTransactionId: customTxnId, productIdentifier: product.productIdentifier diff --git a/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift b/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift index 738f1d5dd6..4ee3f90c9a 100644 --- a/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift @@ -130,7 +130,7 @@ struct CustomProductTests { let data = json.data(using: .utf8)! let store = try JSONDecoder().decode(ProductStore.self, from: data) #expect(store == .custom) - #expect(store.description == "custom") + #expect(store.description == "CUSTOM") } // MARK: - PaywallLogic.getCustomProducts From 6e55786c413eadbc8b2a2fc73c7e5f54368bdf9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:45:05 +0100 Subject: [PATCH 05/18] Correct free trial behaviour, add formUnion override --- .../Operators/AddPaywallProducts.swift | 31 ++- .../Products/StoreProduct/Entitlement.swift | 8 + .../CustomStoreTransaction.swift | 2 +- .../Transactions/TransactionManager.swift | 176 +++++++++++------- .../Paywall/Request/CustomProductTests.swift | 104 ++++++++--- 5 files changed, 222 insertions(+), 99 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift index 63159b1fbc..7a89f73fd5 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift @@ -93,22 +93,35 @@ extension PaywallRequestManager { /// Fetches custom products from the Superwall API and caches them in /// `storeKitManager.productsById` so they can be used for templating. private func fetchAndCacheCustomProducts(_ customProducts: [Product]) async { - let customProductIds = Set(customProducts.map { $0.id }) - // Skip if all custom products are already cached. - let cachedIds = await Set(storeKitManager.productsById.keys) - if customProductIds.isSubset(of: cachedIds) { + let customProductsById = Dictionary( + uniqueKeysWithValues: customProducts.map { ($0.id, $0) } + ) + let cachedProductsById = await storeKitManager.productsById + let idsNeedingRefresh = Set( + customProductsById.compactMap { id, productItem in + guard let cached = cachedProductsById[id] else { + return id + } + guard cached.isCustomProduct else { + return id + } + return cached.entitlements == productItem.entitlements ? nil : id + } + ) + + if idsNeedingRefresh.isEmpty { return } do { let response = try await network.getSuperwallProducts() - for superwallProduct in response.data where customProductIds.contains(superwallProduct.identifier) { - let entitlements = Set(superwallProduct.entitlements.map { - Entitlement(id: $0.identifier) - }) + for superwallProduct in response.data where idsNeedingRefresh.contains(superwallProduct.identifier) { + guard let productItem = customProductsById[superwallProduct.identifier] else { + continue + } let testProduct = TestStoreProduct( superwallProduct: superwallProduct, - entitlements: entitlements + entitlements: productItem.entitlements ) let storeProduct = StoreProduct(customProduct: testProduct) await storeKitManager.setProduct( diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift index b21fa5854a..9e2b0c319e 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift @@ -478,4 +478,12 @@ public extension Set where Element == Entitlement { let combined = Array(self) + Array(other) return Entitlement.mergePrioritized(combined) } + + /// Updates this set with the elements of the given set, using priority logic. + /// + /// When entitlements with the same ID exist in both sets, keeps the higher priority one + /// and merges their productIds. + mutating func formUnion(_ other: Set) { + self = union(other) + } } diff --git a/Sources/SuperwallKit/StoreKit/Transactions/StoreTransaction/CustomStoreTransaction.swift b/Sources/SuperwallKit/StoreKit/Transactions/StoreTransaction/CustomStoreTransaction.swift index 7299f1643c..0f6e111d4e 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/StoreTransaction/CustomStoreTransaction.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/StoreTransaction/CustomStoreTransaction.swift @@ -28,7 +28,7 @@ struct CustomStoreTransaction: StoreTransactionType { init( customTransactionId: String, productIdentifier: String, - purchaseDate: Date = Date() + purchaseDate: Date ) { self.transactionDate = purchaseDate self.originalTransactionIdentifier = customTransactionId diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index dc1cf97855..b5b03dc8ea 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -619,7 +619,7 @@ final class TransactionManager { product.customTransactionId = UUID().uuidString } - let isFreeTrialAvailable = await receiptManager.isFreeTrialAvailable(for: product) + let isFreeTrialAvailable = await isFreeTrialAvailable(for: product) var isObserved = false if case .observeFunc = purchaseSource { @@ -693,6 +693,45 @@ final class TransactionManager { ) } + private func isFreeTrialAvailable(for product: StoreProduct) async -> Bool { + if product.isCustomProduct { + return await isCustomProductFreeTrialAvailable(for: product) + } + + return await receiptManager.isFreeTrialAvailable(for: product) + } + + /// Custom products don't have StoreKit intro-offer state, so use entitlement history + /// to decide whether a trial should count as available. + private func isCustomProductFreeTrialAvailable(for product: StoreProduct) async -> Bool { + guard product.hasFreeTrial else { + return false + } + + if product.entitlements.isEmpty { + return false + } + + let customerInfo = await MainActor.run { + Superwall.shared.customerInfo + } + + // If customer info hasn't loaded yet, assume the user has already had the + // entitlement to avoid falsely counting a trial. + if customerInfo.isPlaceholder { + return false + } + + let productEntitlementIds = Set(product.entitlements.map(\.id)) + let userEntitlementIds = Set( + customerInfo.entitlements + .filter { $0.latestProductId != nil || $0.store == .superwall || $0.isActive } + .map(\.id) + ) + + return productEntitlementIds.isDisjoint(with: userEntitlementIds) + } + /// Dismisses the view controller, if the developer hasn't disabled the option. func didPurchase() async { let coordinator = factory.makePurchasingCoordinator() @@ -702,41 +741,10 @@ final class TransactionManager { else { return } - - // For custom products, create a CustomStoreTransaction instead of - // querying StoreKit. Skip receipt loading since there's no App Store receipt. - if product.isCustomProduct, - let customTxnId = product.customTransactionId { - let customTransaction = CustomStoreTransaction( - customTransactionId: customTxnId, - productIdentifier: product.productIdentifier - ) - let transaction = await factory.makeStoreTransaction(from: customTransaction) - await trackTransactionDidSucceed(transaction) - - if case let .internal(_, paywallViewController, shouldDismiss) = source { - let superwallOptions = factory.makeSuperwallOptions() - let shouldDismissPaywall = superwallOptions.paywalls.automaticallyDismiss && shouldDismiss - if shouldDismissPaywall { - await Superwall.shared.dismiss( - paywallViewController, - result: .purchased(product) - ) - } - if !shouldDismissPaywall { - await MainActor.run { - paywallViewController.togglePaywallSpinner(isHidden: true) - } - } - } - return - } + let purchaseDate = await coordinator.purchaseDate switch source { case let .internal(_, paywallViewController, shouldDismiss): - guard let product = await coordinator.product else { - return - } Logger.debug( logLevel: .debug, scope: .transactions, @@ -748,32 +756,17 @@ final class TransactionManager { error: nil ) - let purchasingCoordinator = factory.makePurchasingCoordinator() - let transaction = await purchasingCoordinator.getLatestTransaction( - forProductId: product.productIdentifier, - factory: factory + let transaction = await latestTransaction( + for: product, + purchaseDate: purchaseDate ) - - // Skip receipt loading in test mode - we've already set the subscription status - let testModeManager = factory.makeTestModeManager() - if !testModeManager.isTestMode { - await receiptManager.loadPurchasedProducts(config: nil) - } + await loadPurchasedProductsIfNeeded(for: product) await trackTransactionDidSucceed(transaction) - - let superwallOptions = factory.makeSuperwallOptions() - let shouldDismissPaywall = superwallOptions.paywalls.automaticallyDismiss && shouldDismiss - if shouldDismissPaywall { - await Superwall.shared.dismiss( - paywallViewController, - result: .purchased(product) - ) - } - if !shouldDismissPaywall { - await MainActor.run { - paywallViewController.togglePaywallSpinner(isHidden: true) - } - } + await finalizeInternalPurchase( + for: product, + paywallViewController: paywallViewController, + shouldDismiss: shouldDismiss + ) case .purchaseFunc, .observeFunc: Logger.debug( @@ -786,15 +779,72 @@ final class TransactionManager { error: nil ) - let purchasingCoordinator = factory.makePurchasingCoordinator() - let transaction = await purchasingCoordinator.getLatestTransaction( - forProductId: product.productIdentifier, - factory: factory + let transaction = await latestTransaction( + for: product, + purchaseDate: purchaseDate ) + await loadPurchasedProductsIfNeeded(for: product) + await trackTransactionDidSucceed(transaction) + } + } - await receiptManager.loadPurchasedProducts(config: nil) + private func latestTransaction( + for product: StoreProduct, + purchaseDate: Date? + ) async -> StoreTransaction? { + if product.isCustomProduct, + let customTxnId = product.customTransactionId, + let purchaseDate { + let customTransaction = CustomStoreTransaction( + customTransactionId: customTxnId, + productIdentifier: product.productIdentifier, + purchaseDate: purchaseDate + ) + return await factory.makeStoreTransaction(from: customTransaction) + } - await trackTransactionDidSucceed(transaction) + let purchasingCoordinator = factory.makePurchasingCoordinator() + return await purchasingCoordinator.getLatestTransaction( + forProductId: product.productIdentifier, + factory: factory + ) + } + + private func loadPurchasedProductsIfNeeded(for product: StoreProduct) async { + guard !shouldSkipReceiptLoading(for: product) else { + return + } + + await receiptManager.loadPurchasedProducts(config: nil) + } + + private func shouldSkipReceiptLoading(for product: StoreProduct) -> Bool { + if product.isCustomProduct { + return true + } + + // Test mode already sets subscription state without StoreKit/receipt data. + return factory.makeTestModeManager().isTestMode + } + + private func finalizeInternalPurchase( + for product: StoreProduct, + paywallViewController: PaywallViewController, + shouldDismiss: Bool + ) async { + let superwallOptions = factory.makeSuperwallOptions() + let shouldDismissPaywall = superwallOptions.paywalls.automaticallyDismiss && shouldDismiss + + if shouldDismissPaywall { + await Superwall.shared.dismiss( + paywallViewController, + result: .purchased(product) + ) + return + } + + await MainActor.run { + paywallViewController.togglePaywallSpinner(isHidden: true) } } diff --git a/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift b/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift index 4ee3f90c9a..d37f4f6b6b 100644 --- a/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift @@ -13,6 +13,30 @@ import Testing /// Tests for custom product model decoding, StoreProduct integration, /// custom transaction creation, and trial eligibility. struct CustomProductTests { + private func makeCustomStoreProduct( + id: String = "custom_prod_1", + trialPeriodDays: Int = 7, + entitlements: Set = [Entitlement(id: "premium", type: .serviceLevel, isActive: false)] + ) -> StoreProduct { + let superwallProduct = SuperwallProduct( + object: "product", + identifier: id, + platform: .custom, + price: SuperwallProductPrice(amount: 999, currency: "USD"), + subscription: SuperwallProductSubscription( + period: .month, + periodCount: 1, + trialPeriodDays: trialPeriodDays + ), + entitlements: [], + storefront: "USA" + ) + let testProduct = TestStoreProduct( + superwallProduct: superwallProduct, + entitlements: entitlements + ) + return StoreProduct(customProduct: testProduct) + } // MARK: - CustomStoreProduct Decoding @@ -179,24 +203,7 @@ struct CustomProductTests { @Test func storeProduct_customInit_setsIsCustomProduct() { - let superwallProduct = SuperwallProduct( - object: "product", - identifier: "custom_prod_1", - platform: .custom, - price: SuperwallProductPrice(amount: 999, currency: "USD"), - subscription: SuperwallProductSubscription( - period: .month, - periodCount: 1, - trialPeriodDays: 7 - ), - entitlements: [], - storefront: "USA" - ) - let testProduct = TestStoreProduct( - superwallProduct: superwallProduct, - entitlements: [] - ) - let storeProduct = StoreProduct(customProduct: testProduct) + let storeProduct = makeCustomStoreProduct(entitlements: []) #expect(storeProduct.isCustomProduct) #expect(storeProduct.customTransactionId == nil) @@ -336,16 +343,61 @@ struct CustomProductTests { } @Test - func customStoreTransaction_defaultPurchaseDate() { - let before = Date() - let transaction = CustomStoreTransaction( - customTransactionId: "txn-1", - productIdentifier: "prod-1" + func prepareToPurchase_customProduct_marksFreeTrialAvailableWhenUserHasNoPriorEntitlement() async { + let dependencyContainer = DependencyContainer() + let product = makeCustomStoreProduct() + let superwall = Superwall.shared + let originalCustomerInfo = superwall.customerInfo + defer { + superwall.customerInfo = originalCustomerInfo + } + + superwall.customerInfo = CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [] + ) + + await dependencyContainer.transactionManager.prepareToPurchase( + product: product, + purchaseSource: .purchaseFunc(product) + ) + + let coordinator = dependencyContainer.makePurchasingCoordinator() + #expect(await coordinator.isFreeTrialAvailable) + } + + @Test + func prepareToPurchase_customProduct_doesNotMarkFreeTrialAvailableWhenUserHadEntitlement() async { + let dependencyContainer = DependencyContainer() + let product = makeCustomStoreProduct() + let superwall = Superwall.shared + let originalCustomerInfo = superwall.customerInfo + defer { + superwall.customerInfo = originalCustomerInfo + } + + superwall.customerInfo = CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [ + Entitlement( + id: "premium", + type: .serviceLevel, + isActive: false, + latestProductId: "old_product", + store: .custom + ) + ] + ) + + await dependencyContainer.transactionManager.prepareToPurchase( + product: product, + purchaseSource: .purchaseFunc(product) ) - let after = Date() - #expect(transaction.transactionDate! >= before) - #expect(transaction.transactionDate! <= after) + let coordinator = dependencyContainer.makePurchasingCoordinator() + #expect(!(await coordinator.isFreeTrialAvailable)) } // MARK: - Custom Trial Eligibility From 20c2015d6083f7197e35e5ee31b3bf81922eeb1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:48:21 +0100 Subject: [PATCH 06/18] Update version, add to changelog --- CHANGELOG.md | 7 +++++++ Sources/SuperwallKit/Misc/Constants.swift | 2 +- SuperwallKit.podspec | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0962efffee..1ba80fe04f 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.15.0 + +### Enhancements + +- Adds support for custom store products. This allows you to purchase products that are on stores outside of the App Store. +- Adds `formUnion` override when unioning sets of `Entitlement` objects. + ## 4.14.1 ### Enhancements diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 60206882a8..19f2179977 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.14.1 +4.15.0 """ diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index 7e0aff9f4a..cd0272c647 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.14.1" + s.version = "4.15.0" s.summary = "Superwall: In-App Paywalls Made Easy" s.description = "Paywall infrastructure for mobile apps :) we make things like editing your paywall and running price tests as easy as clicking a few buttons. superwall.com" From 9ebc779ad5c3465eec2fba6bb29d4d8f878861e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:10:44 +0100 Subject: [PATCH 07/18] small fixes --- .../Paywall/Request/Operators/AddPaywallProducts.swift | 5 +++++ .../StoreKit/Transactions/TransactionManager.swift | 5 ++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift index 7a89f73fd5..129abf3796 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift @@ -419,6 +419,11 @@ extension PaywallRequestManager { } if storeProduct.hasFreeTrial { if productItem.entitlements.isEmpty { + Logger.debug( + logLevel: .warn, + scope: .productsManager, + message: "Custom product \(productItem.id) has a free trial but no entitlements — skipping trial eligibility check." + ) continue } let hasEntitlement = await hasEverHadEntitlement( diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index b5b03dc8ea..583c66b9ca 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -793,12 +793,11 @@ final class TransactionManager { purchaseDate: Date? ) async -> StoreTransaction? { if product.isCustomProduct, - let customTxnId = product.customTransactionId, - let purchaseDate { + let customTxnId = product.customTransactionId { let customTransaction = CustomStoreTransaction( customTransactionId: customTxnId, productIdentifier: product.productIdentifier, - purchaseDate: purchaseDate + purchaseDate: purchaseDate ?? Date() ) return await factory.makeStoreTransaction(from: customTransaction) } From 4978f36f039d143a28d0aeb29f0e17fdaf44d913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:23:16 +0100 Subject: [PATCH 08/18] Update CustomProductTests.swift --- .../Paywall/Request/CustomProductTests.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift b/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift index d37f4f6b6b..a95f8e8191 100644 --- a/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift @@ -467,7 +467,7 @@ struct CustomProductTests { } /// Helper to create a StoreProduct backed by a TestStoreProduct with trial. - private func makeCustomStoreProduct( + private func makeCustomStoreProductForTrialEligibility( id: String = "custom_prod_1", trialDays: Int? = nil ) -> StoreProduct { @@ -501,7 +501,7 @@ struct CustomProductTests { let productItem = makeCustomProductItem( entitlements: [premiumEntitlement] ) - let storeProduct = makeCustomStoreProduct(trialDays: 7) + let storeProduct = makeCustomStoreProductForTrialEligibility(trialDays: 7) let result = checkCustomTrialEligibility( productItems: [productItem], @@ -523,7 +523,7 @@ struct CustomProductTests { let productItem = makeCustomProductItem( entitlements: [premiumEntitlement] ) - let storeProduct = makeCustomStoreProduct(trialDays: 7) + let storeProduct = makeCustomStoreProductForTrialEligibility(trialDays: 7) let userEntitlement = Entitlement( id: "premium", @@ -553,7 +553,7 @@ struct CustomProductTests { let productItem = makeCustomProductItem( entitlements: [premiumEntitlement] ) - let storeProduct = makeCustomStoreProduct(trialDays: nil) + let storeProduct = makeCustomStoreProductForTrialEligibility(trialDays: nil) let result = checkCustomTrialEligibility( productItems: [productItem], @@ -575,7 +575,7 @@ struct CustomProductTests { let productItem = makeCustomProductItem( entitlements: [premiumEntitlement] ) - let storeProduct = makeCustomStoreProduct(trialDays: 7) + let storeProduct = makeCustomStoreProductForTrialEligibility(trialDays: 7) let result = checkCustomTrialEligibility( productItems: [productItem], @@ -590,7 +590,7 @@ struct CustomProductTests { @Test func customTrialEligibility_noEntitlementsConfigured_skipsProduct() { let productItem = makeCustomProductItem(entitlements: []) - let storeProduct = makeCustomStoreProduct(trialDays: 7) + let storeProduct = makeCustomStoreProductForTrialEligibility(trialDays: 7) let result = checkCustomTrialEligibility( productItems: [productItem], @@ -634,7 +634,7 @@ struct CustomProductTests { let productItem = makeCustomProductItem( entitlements: [premiumEntitlement] ) - let storeProduct = makeCustomStoreProduct(trialDays: 7) + let storeProduct = makeCustomStoreProductForTrialEligibility(trialDays: 7) // Config-only placeholder: no latestProductId, no store, not active let placeholderEntitlement = Entitlement( From 273a9db477d2a283a2765a48e4eb0669c15fed23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:29:31 +0100 Subject: [PATCH 09/18] Add trial period price to test/custom products --- CHANGELOG.md | 4 +++ .../SuperwallKit/Config/ConfigManager.swift | 4 +-- .../Network/V2ProductsResponse.swift | 4 +++ .../Operators/AddPaywallProducts.swift | 2 +- ...oreProduct.swift => APIStoreProduct.swift} | 34 +++++++++++++++---- .../Products/StoreProduct/StoreProduct.swift | 4 +-- 6 files changed, 41 insertions(+), 11 deletions(-) rename Sources/SuperwallKit/StoreKit/Products/StoreProduct/{TestStoreProduct.swift => APIStoreProduct.swift} (88%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87c78798d4..60e64b797e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Adds `formUnion` override when unioning sets of `Entitlement` objects. - Adds multipage paywall navigation tracking by tracking a `paywall_page_view` event, which contains information about the page view. +### Fixes + +- Fixes issue where test mode products had trial price data missing. + ## 4.14.1 ### Enhancements diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 94ab034fc0..5fb86e0f6a 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -686,11 +686,11 @@ class ConfigManager { let entitlements = Set(superwallProduct.entitlements.map { Entitlement(id: $0.identifier) }) - let testProduct = TestStoreProduct( + let apiProduct = APIStoreProduct( superwallProduct: superwallProduct, entitlements: entitlements ) - let storeProduct = StoreProduct(testProduct: testProduct) + let storeProduct = StoreProduct(testProduct: apiProduct) await storeKitManager.setProduct(storeProduct, forIdentifier: superwallProduct.identifier) } } catch { diff --git a/Sources/SuperwallKit/Network/V2ProductsResponse.swift b/Sources/SuperwallKit/Network/V2ProductsResponse.swift index 400a8a56ee..07b98f8011 100644 --- a/Sources/SuperwallKit/Network/V2ProductsResponse.swift +++ b/Sources/SuperwallKit/Network/V2ProductsResponse.swift @@ -76,10 +76,14 @@ public struct SuperwallProductSubscription: Decodable, Sendable { /// The number of trial days, if any. public let trialPeriodDays: Int? + /// The trial period price, if any. + public let trialPeriodPrice: SuperwallProductPrice? + enum CodingKeys: String, CodingKey { case period case periodCount = "period_count" case trialPeriodDays = "trial_period_days" + case trialPeriodPrice = "trial_period_price" } } diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift index 129abf3796..134b9f9501 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift @@ -119,7 +119,7 @@ extension PaywallRequestManager { guard let productItem = customProductsById[superwallProduct.identifier] else { continue } - let testProduct = TestStoreProduct( + let testProduct = APIStoreProduct( superwallProduct: superwallProduct, entitlements: productItem.entitlements ) diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/TestStoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift similarity index 88% rename from Sources/SuperwallKit/StoreKit/Products/StoreProduct/TestStoreProduct.swift rename to Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift index aacad80e8d..32e0f633ac 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/TestStoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift @@ -1,5 +1,5 @@ // -// TestStoreProduct.swift +// APIStoreProduct.swift // SuperwallKit // // Created by Yusuf Tör on 2026-01-27. @@ -12,7 +12,7 @@ import StoreKit /// A `StoreProductType` backed by a `SuperwallProduct` from the Superwall API. /// /// Used for test store products that are not fetched from StoreKit. -struct TestStoreProduct: StoreProductType { +struct APIStoreProduct: StoreProductType { let superwallProduct: SuperwallProduct let entitlements: Set @@ -261,14 +261,36 @@ struct TestStoreProduct: StoreProductType { return formatter.string(from: date) } + private var rawTrialPeriodPrice: Decimal { + guard let amount = superwallProduct.subscription?.trialPeriodPrice?.amount else { return 0 } + return Decimal(amount) / 100 + } + var localizedTrialPeriodPrice: String { - priceFormatter.string(from: 0) ?? "$0.00" + priceFormatter.string(from: NSDecimalNumber(decimal: rawTrialPeriodPrice)) ?? "$0.00" } - var trialPeriodPrice: Decimal { 0 } + var trialPeriodPrice: Decimal { rawTrialPeriodPrice } func trialPeriodPricePerUnit(_ unit: SubscriptionPeriod.Unit) -> String { - priceFormatter.string(from: 0) ?? "$0.00" + guard rawTrialPeriodPrice != 0, let subUnit = subscriptionUnit else { + return priceFormatter.string(from: NSDecimalNumber(decimal: rawTrialPeriodPrice)) ?? "$0.00" + } + let trialDays = Decimal(superwallProduct.subscription?.trialPeriodDays ?? 0) + guard trialDays > 0 else { + return priceFormatter.string(from: NSDecimalNumber(decimal: rawTrialPeriodPrice)) ?? "$0.00" + } + let dailyPrice = rawTrialPeriodPrice / trialDays + let multiplier: Decimal + switch unit { + case .day: multiplier = 1 + case .week: multiplier = 7 + case .month: multiplier = Decimal(365) / Decimal(12) + case .year: multiplier = 365 + @unknown default: multiplier = 1 + } + let unitPrice = (dailyPrice * multiplier).roundedPrice() + return priceFormatter.string(from: NSDecimalNumber(decimal: unitPrice)) ?? "n/a" } var trialPeriodDays: Int { @@ -328,7 +350,7 @@ struct TestStoreProduct: StoreProductType { // MARK: - SWProduct Init extension SWProduct { - init(product: TestStoreProduct) { + init(product: APIStoreProduct) { localizedDescription = "" localizedTitle = product.superwallProduct.identifier price = product.price diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift index e935c82c20..e63e58f001 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift @@ -393,11 +393,11 @@ public final class StoreProduct: NSObject, StoreProductType, Sendable { self.init(SK2StoreProduct(sk2Product: sk2Product, entitlements: entitlements)) } - convenience init(testProduct: TestStoreProduct) { + convenience init(testProduct: APIStoreProduct) { self.init(testProduct) } - convenience init(customProduct: TestStoreProduct) { + convenience init(customProduct: APIStoreProduct) { self.init(customProduct) self.isCustomProduct = true } From 784403893ba9a0617328d9202f902324ccfd28b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:46:32 +0100 Subject: [PATCH 10/18] test fixes --- SuperwallKit.xcodeproj/project.pbxproj | 8 ++--- .../Paywall/Request/CustomProductTests.swift | 33 +++++++++++-------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 3fda780943..dae7c8fc77 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -339,7 +339,7 @@ A59E22688D68CBE09FF78D57 /* IntroOfferEligibilityRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08AEAA8E3B5F51848523AE61 /* IntroOfferEligibilityRequest.swift */; }; A646BB605400E4BDD321F389 /* SurveyShowCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E2D026C30691F11D4E839F /* SurveyShowCondition.swift */; }; A6917A68A4342B1BC41B8DE9 /* UIWindow+SwizzleSendEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D275ED98D2EE298F06708AF /* UIWindow+SwizzleSendEvent.swift */; }; - A6E214D4B9B79C72E1E28212 /* TestStoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4998307C63AC04F4CC87F119 /* TestStoreProduct.swift */; }; + A6E214D4B9B79C72E1E28212 /* APIStoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4998307C63AC04F4CC87F119 /* APIStoreProduct.swift */; }; A73497BB3DCD881318A5CD86 /* ConfigLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97D7F499B2CBFFF0A61F8D72 /* ConfigLogicTests.swift */; }; A74BBEF8CCF5A35AB19BB70C /* ProductPurchaserLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A413B6FF46B130D90A428B4 /* ProductPurchaserLogic.swift */; }; A78DC9BD71DD85831025D620 /* SuperwallGraveyard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36898353ABCE620BA7AEE59 /* SuperwallGraveyard.swift */; }; @@ -720,7 +720,7 @@ 481D47E5121C521DDA268609 /* TriggerRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerRule.swift; sourceTree = ""; }; 4827295A4E093CAEE2207DDF /* ConfigResponseLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigResponseLogicTests.swift; sourceTree = ""; }; 498D6155C2B8B18E7F3D0E79 /* Validation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Validation.swift; sourceTree = ""; }; - 4998307C63AC04F4CC87F119 /* TestStoreProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestStoreProduct.swift; sourceTree = ""; }; + 4998307C63AC04F4CC87F119 /* APIStoreProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIStoreProduct.swift; sourceTree = ""; }; 49E522F5BCABB3A95B97549E /* PurchaseError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseError.swift; sourceTree = ""; }; 4B001199B53C314F25788603 /* PassableValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassableValue.swift; sourceTree = ""; }; 4B1DC32C4ABB60B8323E5D28 /* AsyncSequence+Extract.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncSequence+Extract.swift"; sourceTree = ""; }; @@ -2724,7 +2724,7 @@ F5A959F1F550446C980DC5E5 /* StoreProductType.swift */, 6DE89E115B095A63FAC09719 /* StripeProductType.swift */, D1443B535E6E1D572A74733F /* SubscriptionPeriod.swift */, - 4998307C63AC04F4CC87F119 /* TestStoreProduct.swift */, + 4998307C63AC04F4CC87F119 /* APIStoreProduct.swift */, 10A0D5F687A5EF3EE39B4DCA /* Discount */, ); path = StoreProduct; @@ -3657,7 +3657,7 @@ 9C92AA4586E7013F37C03294 /* TestModePurchaseDrawer.swift in Sources */, 3A4A22150A6EB1C234BAC722 /* TestModeRestoreDrawer.swift in Sources */, 999CEB0F1A2C8A7CAEE831BB /* TestModeTransactionHandler.swift in Sources */, - A6E214D4B9B79C72E1E28212 /* TestStoreProduct.swift in Sources */, + A6E214D4B9B79C72E1E28212 /* APIStoreProduct.swift in Sources */, EA50607230AA07B509E90E10 /* TestStoreUser.swift in Sources */, 31DE588B2B4A26745C33753C /* ThrowableDecodable.swift in Sources */, 6FE965326C139AB101BAC1CF /* Trackable.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift b/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift index a95f8e8191..d3976ee063 100644 --- a/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift @@ -26,12 +26,13 @@ struct CustomProductTests { subscription: SuperwallProductSubscription( period: .month, periodCount: 1, - trialPeriodDays: trialPeriodDays + trialPeriodDays: trialPeriodDays, + trialPeriodPrice: nil ), entitlements: [], storefront: "USA" ) - let testProduct = TestStoreProduct( + let testProduct = APIStoreProduct( superwallProduct: superwallProduct, entitlements: entitlements ) @@ -221,7 +222,7 @@ struct CustomProductTests { entitlements: [], storefront: "USA" ) - let testProduct = TestStoreProduct( + let testProduct = APIStoreProduct( superwallProduct: superwallProduct, entitlements: [] ) @@ -230,7 +231,7 @@ struct CustomProductTests { #expect(!storeProduct.isCustomProduct) } - // MARK: - TestStoreProduct attribute computation + // MARK: - APIStoreProduct attribute computation @Test func testStoreProduct_computesPrice() { @@ -242,12 +243,13 @@ struct CustomProductTests { subscription: SuperwallProductSubscription( period: .month, periodCount: 1, - trialPeriodDays: nil + trialPeriodDays: nil, + trialPeriodPrice: nil ), entitlements: [], storefront: "USA" ) - let testProduct = TestStoreProduct( + let testProduct = APIStoreProduct( superwallProduct: superwallProduct, entitlements: [] ) @@ -267,12 +269,13 @@ struct CustomProductTests { subscription: SuperwallProductSubscription( period: .year, periodCount: 1, - trialPeriodDays: 14 + trialPeriodDays: 14, + trialPeriodPrice: nil ), entitlements: [], storefront: "USA" ) - let testProduct = TestStoreProduct( + let testProduct = APIStoreProduct( superwallProduct: superwallProduct, entitlements: [] ) @@ -296,7 +299,7 @@ struct CustomProductTests { entitlements: [], storefront: "USA" ) - let testProduct = TestStoreProduct( + let testProduct = APIStoreProduct( superwallProduct: superwallProduct, entitlements: [] ) @@ -466,7 +469,7 @@ struct CustomProductTests { ) } - /// Helper to create a StoreProduct backed by a TestStoreProduct with trial. + /// Helper to create a StoreProduct backed by an APIStoreProduct with trial. private func makeCustomStoreProductForTrialEligibility( id: String = "custom_prod_1", trialDays: Int? = nil @@ -479,12 +482,13 @@ struct CustomProductTests { subscription: SuperwallProductSubscription( period: .month, periodCount: 1, - trialPeriodDays: trialDays + trialPeriodDays: trialDays, + trialPeriodPrice: nil ), entitlements: [], storefront: "USA" ) - let testProduct = TestStoreProduct( + let testProduct = APIStoreProduct( superwallProduct: superwallProduct, entitlements: [] ) @@ -675,12 +679,13 @@ struct CustomProductTests { subscription: SuperwallProductSubscription( period: .month, periodCount: 1, - trialPeriodDays: nil + trialPeriodDays: nil, + trialPeriodPrice: nil ), entitlements: [], storefront: "USA" ) - let testProduct = TestStoreProduct( + let testProduct = APIStoreProduct( superwallProduct: superwallProduct, entitlements: [] ) From 6eb8fd102198b664ff4968664d88844fa7cd2796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:57:42 +0100 Subject: [PATCH 11/18] Update AddPaywallProducts.swift --- .../Operators/AddPaywallProducts.swift | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift index 134b9f9501..c9a41aa6c3 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift @@ -93,9 +93,22 @@ extension PaywallRequestManager { /// Fetches custom products from the Superwall API and caches them in /// `storeKitManager.productsById` so they can be used for templating. private func fetchAndCacheCustomProducts(_ customProducts: [Product]) async { - let customProductsById = Dictionary( - uniqueKeysWithValues: customProducts.map { ($0.id, $0) } - ) + var duplicateCustomProductIds = Set() + let customProductsById = customProducts.reduce(into: [String: Product]()) { result, product in + if result.updateValue(product, forKey: product.id) != nil { + duplicateCustomProductIds.insert(product.id) + } + } + + if !duplicateCustomProductIds.isEmpty { + let duplicateIds = duplicateCustomProductIds.sorted().joined(separator: ", ") + Logger.debug( + logLevel: .warn, + scope: .productsManager, + message: "Paywall contains duplicate custom product ids: \(duplicateIds). Using the last occurrence." + ) + } + let cachedProductsById = await storeKitManager.productsById let idsNeedingRefresh = Set( customProductsById.compactMap { id, productItem in From 61795217648f52dea121caf67820c1baaf36a930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:54:54 +0100 Subject: [PATCH 12/18] bug fix --- Sources/SuperwallKit/Config/ConfigLogic.swift | 3 ++- .../View Controller/Web View/Templating/TemplateLogic.swift | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/Config/ConfigLogic.swift b/Sources/SuperwallKit/Config/ConfigLogic.swift index e9124281e8..15b6647765 100644 --- a/Sources/SuperwallKit/Config/ConfigLogic.swift +++ b/Sources/SuperwallKit/Config/ConfigLogic.swift @@ -306,7 +306,8 @@ enum ConfigLogic { from config: Config ) -> [String: Set] { return Dictionary( - uniqueKeysWithValues: config.products.map { ($0.id, $0.entitlements) } + config.products.map { ($0.id, $0.entitlements) }, + uniquingKeysWith: { $0.union($1) } ) } } diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/TemplateLogic.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/TemplateLogic.swift index 5e059bb620..d3c97070b1 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/TemplateLogic.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Templating/TemplateLogic.swift @@ -18,7 +18,7 @@ enum TemplateLogic { ) async -> String { let productsTemplate = ProductTemplate( eventName: "products", - products: TemplatingProductItem.create(from: paywall.appStoreProducts) + products: TemplatingProductItem.create(from: paywall.products) ) // Dynamically set isSubscribed for each product From d48e236d7ca3edb1ec4061385626fb3b38b36568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:18:43 +0100 Subject: [PATCH 13/18] Align custom product price formatting with SK2 and guard against missing PurchaseController Route APIStoreProduct price formatting through the shared PriceFormatterProvider so custom products render currencies consistently with real App Store products on the same paywall. Also surface an explicit error when a custom product is purchased without an external PurchaseController, since the default SK1/SK2 purchasers bail silently and leave the paywall stuck. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Products/StoreProduct/APIStoreProduct.swift | 17 +++++------------ .../Transactions/TransactionManager.swift | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift index 32e0f633ac..4a3db77711 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift @@ -43,10 +43,7 @@ struct APIStoreProduct: StoreProductType { } var localizedPrice: String { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = superwallProduct.price?.currency.uppercased() ?? "USD" - return formatter.string(from: NSDecimalNumber(decimal: price)) ?? "$\(price)" + return priceFormatter.string(from: NSDecimalNumber(decimal: price)) ?? "$\(price)" } var currencyCode: String? { @@ -54,10 +51,7 @@ struct APIStoreProduct: StoreProductType { } var currencySymbol: String? { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = currencyCode ?? "USD" - return formatter.currencySymbol + priceFormatter.currencySymbol } let subscriptionGroupIdentifier: String? = nil @@ -168,10 +162,9 @@ struct APIStoreProduct: StoreProductType { // MARK: - Computed Prices private var priceFormatter: NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = currencyCode ?? "USD" - return formatter + return priceFormatterProvider.priceFormatterForSK2( + withCurrencyCode: currencyCode ?? "USD" + ) } var dailyPrice: String { diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index 583c66b9ca..c3639c56c4 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -486,6 +486,20 @@ final class TransactionManager { ) } + // Custom products can only be purchased via an external PurchaseController. + // Without one, the default SK1/SK2 purchasers have no underlying product to + // transact against and bail silently, leaving the paywall stuck. + if product.isCustomProduct, + !factory.makeHasExternalPurchaseController() { + Logger.debug( + logLevel: .error, + scope: .transactions, + message: "Custom product \"\(product.productIdentifier)\" can only be purchased using a PurchaseController. " + + "Set one via Superwall.configure(..., purchaseController:) to handle custom product purchases." + ) + return .failed(PurchaseError.productUnavailable) + } + // Attach intro offer token if available from the paywall if case .internal(_, let paywallViewController, _) = purchaseSource { product.introOfferToken = await paywallViewController From 5b11417ddbc4b3f3073381c5eb8831e0d49d577e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:22:01 +0100 Subject: [PATCH 14/18] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60e64b797e..a14d6b2827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Enhancements -- Adds support for custom store products. This allows you to purchase products that are on stores outside of the App Store. +- Adds support for custom store products. This allows you to purchase products that are on stores outside of the App Store using the `PurchaseController`. - Adds `formUnion` override when unioning sets of `Entitlement` objects. - Adds multipage paywall navigation tracking by tracking a `paywall_page_view` event, which contains information about the page view. From fe03098993bd84071a69bd2b4e49a729706802a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:23:17 +0100 Subject: [PATCH 15/18] Update CHANGELOG.md --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a14d6b2827..7f2bd3de68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,17 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Adds support for custom store products. This allows you to purchase products that are on stores outside of the App Store using the `PurchaseController`. - Adds `formUnion` override when unioning sets of `Entitlement` objects. -- Adds multipage paywall navigation tracking by tracking a `paywall_page_view` event, which contains information about the page view. ### Fixes - Fixes issue where test mode products had trial price data missing. +## 4.14.2 + +### Enhancements + +- Adds multipage paywall navigation tracking by tracking a `paywall_page_view` event, which contains information about the page view. + ## 4.14.1 ### Enhancements From fdf2247528d956fe1f390ca2fbbeacb3c3adc7e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:08:14 +0100 Subject: [PATCH 16/18] Update APIStoreProduct.swift --- .../StoreKit/Products/StoreProduct/APIStoreProduct.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift index 4a3db77711..5183c44331 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift @@ -266,7 +266,10 @@ struct APIStoreProduct: StoreProductType { var trialPeriodPrice: Decimal { rawTrialPeriodPrice } func trialPeriodPricePerUnit(_ unit: SubscriptionPeriod.Unit) -> String { - guard rawTrialPeriodPrice != 0, let subUnit = subscriptionUnit else { + guard + rawTrialPeriodPrice != 0, + subscriptionUnit != nil + else { return priceFormatter.string(from: NSDecimalNumber(decimal: rawTrialPeriodPrice)) ?? "$0.00" } let trialDays = Decimal(superwallProduct.subscription?.trialPeriodDays ?? 0) From 182d223914826f932fa364ed2d553a398dc1bbe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:29:59 +0100 Subject: [PATCH 17/18] Derive custom product price locale from storefront and add explicit purchase error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the APIStoreProduct price formatter locale from the SuperwallProduct storefront so currency rendering matches what Apple returns for App Store products — USD on any device now renders as `$3.99`, not `US$3.99`. Introduce `PurchaseError.customProductWithoutPurchaseController` so the failure is distinguishable from generic productUnavailable, while keeping the user-facing message short. The developer-facing hint lives in the log. Also fixes a stale docstring on APIStoreProduct, removes an unused `subUnit` binding flagged in review, and silences function_body_length warnings introduced by the diagnostic debugPrints. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Operators/AddPaywallProducts.swift | 37 +++++++++++++++++++ .../StoreProduct/APIStoreProduct.swift | 24 +++++++++++- .../Purchasing/PurchaseError.swift | 3 ++ .../Transactions/TransactionManager.swift | 2 +- 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift index c9a41aa6c3..9998502900 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift @@ -30,12 +30,19 @@ extension PaywallRequestManager { return paywall } + // swiftlint:disable:next function_body_length private func getProducts(for paywall: Paywall, request: PaywallRequest) async throws -> Paywall { var paywall = paywall + debugPrint( + "[SW-CUSTOM] getProducts start — paywall.products ids+types:", + paywall.products.map { "\($0.id)(\($0.type))" } + ) + // Pre-populate custom products from the Superwall API before fetching // App Store products so they're already cached in productsById. let customProducts = paywall.customProducts + debugPrint("[SW-CUSTOM] customProducts filtered:", customProducts.map { $0.id }) if !customProducts.isEmpty { await fetchAndCacheCustomProducts(customProducts) } @@ -48,21 +55,39 @@ extension PaywallRequestManager { isTestMode: factory.isTestMode ) + debugPrint( + "[SW-CUSTOM] storeKitManager.getProducts returned productItems:", + result.productItems.map { "\($0.id)(\($0.type))" } + ) + debugPrint("[SW-CUSTOM] storeKitManager.getProducts returned productsById keys:", Array(result.productsById.keys)) + paywall.products = result.productItems + debugPrint("[SW-CUSTOM] after assign paywall.products:", paywall.products.map { "\($0.id)(\($0.type))" }) + // Merge custom products into productsById so they appear in // product variables and templating. var mergedProductsById = result.productsById for product in customProducts { if let cached = await storeKitManager.productsById[product.id] { + debugPrint("[SW-CUSTOM] merging cached custom product:", product.id, "price:", cached.localizedPrice) mergedProductsById[product.id] = cached + } else { + debugPrint("[SW-CUSTOM] WARN: custom product NOT cached in storeKitManager.productsById:", product.id) } } + debugPrint("[SW-CUSTOM] mergedProductsById keys:", Array(mergedProductsById.keys)) let outcome = PaywallLogic.getProductVariables( productItems: result.productItems, productsById: mergedProductsById ) + debugPrint( + "[SW-CUSTOM] productVariables count:", + outcome.productVariables.count, + "names:", + outcome.productVariables.map { $0.name } + ) paywall.productVariables = outcome.productVariables return paywall @@ -92,6 +117,7 @@ extension PaywallRequestManager { /// Fetches custom products from the Superwall API and caches them in /// `storeKitManager.productsById` so they can be used for templating. + // swiftlint:disable:next function_body_length private func fetchAndCacheCustomProducts(_ customProducts: [Product]) async { var duplicateCustomProductIds = Set() let customProductsById = customProducts.reduce(into: [String: Product]()) { result, product in @@ -126,10 +152,14 @@ extension PaywallRequestManager { return } + debugPrint("[SW-CUSTOM] fetchAndCacheCustomProducts idsNeedingRefresh:", idsNeedingRefresh) + do { let response = try await network.getSuperwallProducts() + debugPrint("[SW-CUSTOM] /v1/products response ids:", response.data.map { $0.identifier }) for superwallProduct in response.data where idsNeedingRefresh.contains(superwallProduct.identifier) { guard let productItem = customProductsById[superwallProduct.identifier] else { + debugPrint("[SW-CUSTOM] no matching customProductsById for", superwallProduct.identifier) continue } let testProduct = APIStoreProduct( @@ -137,12 +167,19 @@ extension PaywallRequestManager { entitlements: productItem.entitlements ) let storeProduct = StoreProduct(customProduct: testProduct) + debugPrint( + "[SW-CUSTOM] caching custom product", + superwallProduct.identifier, + "price:", + storeProduct.localizedPrice + ) await storeKitManager.setProduct( storeProduct, forIdentifier: superwallProduct.identifier ) } } catch { + debugPrint("[SW-CUSTOM] ERROR fetching /v1/products:", error) Logger.debug( logLevel: .error, scope: .productsManager, diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift index 5183c44331..1fd904b3dc 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift @@ -11,7 +11,9 @@ import StoreKit /// A `StoreProductType` backed by a `SuperwallProduct` from the Superwall API. /// -/// Used for test store products that are not fetched from StoreKit. +/// Used for API-backed products that are not fetched from StoreKit — covers both +/// test store products in test mode and custom products purchased via an external +/// `PurchaseController`. struct APIStoreProduct: StoreProductType { let superwallProduct: SuperwallProduct let entitlements: Set @@ -161,9 +163,27 @@ struct APIStoreProduct: StoreProductType { // MARK: - Computed Prices + /// A locale derived from the product's `storefront` country code so + /// currency rendering matches what Apple returns for App Store products + /// (e.g. USD on any device → `$3.99`, not `US$3.99`). Unrecognized + /// storefronts fall back to `en_US` since Superwall products are USD-only + /// today. + private var storefrontLocale: Locale { + if #available(iOS 16.0, *) { + let region = Locale.Region(superwallProduct.storefront) + if Locale.Region.isoRegions.contains(region) { + var components = Locale.Components(languageCode: .english) + components.region = region + return Locale(components: components) + } + } + return Locale(identifier: "en_US") + } + private var priceFormatter: NumberFormatter { return priceFormatterProvider.priceFormatterForSK2( - withCurrencyCode: currencyCode ?? "USD" + withCurrencyCode: currencyCode ?? "USD", + locale: storefrontLocale ) } diff --git a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchaseError.swift b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchaseError.swift index 2ae61ca948..df178298d2 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchaseError.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/PurchaseError.swift @@ -14,6 +14,7 @@ enum PurchaseError: LocalizedError { case noTransactionDetected case unverifiedTransaction case testModeFailure + case customProductWithoutPurchaseController var errorDescription: String? { switch self { @@ -29,6 +30,8 @@ enum PurchaseError: LocalizedError { return "An unknown error occurred." case .testModeFailure: return "Simulated purchase failure (test mode)." + case .customProductWithoutPurchaseController: + return "Unable to purchase this product." } } } diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index c3639c56c4..17300ce016 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -497,7 +497,7 @@ final class TransactionManager { message: "Custom product \"\(product.productIdentifier)\" can only be purchased using a PurchaseController. " + "Set one via Superwall.configure(..., purchaseController:) to handle custom product purchases." ) - return .failed(PurchaseError.productUnavailable) + return .failed(PurchaseError.customProductWithoutPurchaseController) } // Attach intro offer token if available from the paywall From bdd20de7288a534f390fcb0358c42f629ee6d60c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:53:19 +0100 Subject: [PATCH 18/18] Remove diagnostic [SW-CUSTOM] debugPrints Strip the ~12 debugPrint statements that were left in AddPaywallProducts after local debugging, and drop the two swiftlint:disable annotations that were only needed to keep those functions under the body-length limit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Operators/AddPaywallProducts.swift | 37 ------------------- 1 file changed, 37 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift index 9998502900..c9a41aa6c3 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift @@ -30,19 +30,12 @@ extension PaywallRequestManager { return paywall } - // swiftlint:disable:next function_body_length private func getProducts(for paywall: Paywall, request: PaywallRequest) async throws -> Paywall { var paywall = paywall - debugPrint( - "[SW-CUSTOM] getProducts start — paywall.products ids+types:", - paywall.products.map { "\($0.id)(\($0.type))" } - ) - // Pre-populate custom products from the Superwall API before fetching // App Store products so they're already cached in productsById. let customProducts = paywall.customProducts - debugPrint("[SW-CUSTOM] customProducts filtered:", customProducts.map { $0.id }) if !customProducts.isEmpty { await fetchAndCacheCustomProducts(customProducts) } @@ -55,39 +48,21 @@ extension PaywallRequestManager { isTestMode: factory.isTestMode ) - debugPrint( - "[SW-CUSTOM] storeKitManager.getProducts returned productItems:", - result.productItems.map { "\($0.id)(\($0.type))" } - ) - debugPrint("[SW-CUSTOM] storeKitManager.getProducts returned productsById keys:", Array(result.productsById.keys)) - paywall.products = result.productItems - debugPrint("[SW-CUSTOM] after assign paywall.products:", paywall.products.map { "\($0.id)(\($0.type))" }) - // Merge custom products into productsById so they appear in // product variables and templating. var mergedProductsById = result.productsById for product in customProducts { if let cached = await storeKitManager.productsById[product.id] { - debugPrint("[SW-CUSTOM] merging cached custom product:", product.id, "price:", cached.localizedPrice) mergedProductsById[product.id] = cached - } else { - debugPrint("[SW-CUSTOM] WARN: custom product NOT cached in storeKitManager.productsById:", product.id) } } - debugPrint("[SW-CUSTOM] mergedProductsById keys:", Array(mergedProductsById.keys)) let outcome = PaywallLogic.getProductVariables( productItems: result.productItems, productsById: mergedProductsById ) - debugPrint( - "[SW-CUSTOM] productVariables count:", - outcome.productVariables.count, - "names:", - outcome.productVariables.map { $0.name } - ) paywall.productVariables = outcome.productVariables return paywall @@ -117,7 +92,6 @@ extension PaywallRequestManager { /// Fetches custom products from the Superwall API and caches them in /// `storeKitManager.productsById` so they can be used for templating. - // swiftlint:disable:next function_body_length private func fetchAndCacheCustomProducts(_ customProducts: [Product]) async { var duplicateCustomProductIds = Set() let customProductsById = customProducts.reduce(into: [String: Product]()) { result, product in @@ -152,14 +126,10 @@ extension PaywallRequestManager { return } - debugPrint("[SW-CUSTOM] fetchAndCacheCustomProducts idsNeedingRefresh:", idsNeedingRefresh) - do { let response = try await network.getSuperwallProducts() - debugPrint("[SW-CUSTOM] /v1/products response ids:", response.data.map { $0.identifier }) for superwallProduct in response.data where idsNeedingRefresh.contains(superwallProduct.identifier) { guard let productItem = customProductsById[superwallProduct.identifier] else { - debugPrint("[SW-CUSTOM] no matching customProductsById for", superwallProduct.identifier) continue } let testProduct = APIStoreProduct( @@ -167,19 +137,12 @@ extension PaywallRequestManager { entitlements: productItem.entitlements ) let storeProduct = StoreProduct(customProduct: testProduct) - debugPrint( - "[SW-CUSTOM] caching custom product", - superwallProduct.identifier, - "price:", - storeProduct.localizedPrice - ) await storeKitManager.setProduct( storeProduct, forIdentifier: superwallProduct.identifier ) } } catch { - debugPrint("[SW-CUSTOM] ERROR fetching /v1/products:", error) Logger.debug( logLevel: .error, scope: .productsManager,