diff --git a/CHANGELOG.md b/CHANGELOG.md index eca15b1de5..60e64b797e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,18 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. -## 4.14.2 +## 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. - 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/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/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 41e6bcf49c..19f2179977 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.14.2 +4.15.0 """ diff --git a/Sources/SuperwallKit/Models/Paywall/Paywall.swift b/Sources/SuperwallKit/Models/Paywall/Paywall.swift index 87b942812e..3a41557e58 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..fe4f1ba1d2 100644 --- a/Sources/SuperwallKit/Models/Product/ProductStore.swift +++ b/Sources/SuperwallKit/Models/Product/ProductStore.swift @@ -28,6 +28,9 @@ public enum ProductStore: Int, Codable, Sendable { /// 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 { @@ -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..07b98f8011 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. @@ -75,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 ba8279f19d..c9a41aa6c3 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,70 @@ 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 { + 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 + 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 idsNeedingRefresh.contains(superwallProduct.identifier) { + guard let productItem = customProductsById[superwallProduct.identifier] else { + continue + } + let testProduct = APIStoreProduct( + superwallProduct: superwallProduct, + entitlements: productItem.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 +271,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 +412,42 @@ 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 { + 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( + 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/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/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/Products/StoreProduct/StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift index 5b4b76f8e4..e63e58f001 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 @@ -384,10 +393,15 @@ 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: APIStoreProduct) { + 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..0f6e111d4e --- /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 + ) { + 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..583c66b9ca 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -614,7 +614,12 @@ final class TransactionManager { product: StoreProduct, purchaseSource: PurchaseSource ) async { - let isFreeTrialAvailable = await receiptManager.isFreeTrialAvailable(for: product) + // Always regenerate the custom transaction ID before a new purchase attempt. + if product.isCustomProduct { + product.customTransactionId = UUID().uuidString + } + + let isFreeTrialAvailable = await isFreeTrialAvailable(for: product) var isObserved = false if case .observeFunc = purchaseSource { @@ -688,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() @@ -697,12 +741,10 @@ final class TransactionManager { else { 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, @@ -714,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( @@ -752,15 +779,71 @@ 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) + } + } + + private func latestTransaction( + for product: StoreProduct, + purchaseDate: Date? + ) async -> StoreTransaction? { + if product.isCustomProduct, + let customTxnId = product.customTransactionId { + let customTransaction = CustomStoreTransaction( + customTransactionId: customTxnId, + productIdentifier: product.productIdentifier, + purchaseDate: purchaseDate ?? Date() ) + return await factory.makeStoreTransaction(from: customTransaction) + } - await receiptManager.loadPurchasedProducts(config: nil) + let purchasingCoordinator = factory.makePurchasingCoordinator() + return await purchasingCoordinator.getLatestTransaction( + forProductId: product.productIdentifier, + factory: factory + ) + } - await trackTransactionDidSucceed(transaction) + 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/SuperwallKit.podspec b/SuperwallKit.podspec index 824ebdc635..cd0272c647 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.14.2" + 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" diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 4bcf8d6e88..dae7c8fc77 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 */; }; @@ -270,6 +271,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 */; }; 87B66787F6EB43DA80667C36 /* PageViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E321E7EEC07CA9A8B9A5619 /* PageViewData.swift */; }; @@ -337,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 */; }; @@ -462,6 +464,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 */; }; @@ -584,6 +587,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 = ""; }; @@ -629,6 +633,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 = ""; }; @@ -715,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 = ""; }; @@ -1024,6 +1029,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 = ""; }; @@ -1691,6 +1697,7 @@ 41C20E3AA2F12F64126C7D72 /* StoreTransaction */ = { isa = PBXGroup; children = ( + 0704117EF9F98A047714162B /* CustomStoreTransaction.swift */, 84D6F69BA2B44540FAF05E36 /* SK1StoreTransaction.swift */, C3B96E2A1A289D96267EC0BC /* SK2StoreTransaction.swift */, F6EED7C7E264C38A1A7C3EFB /* StorePayment.swift */, @@ -2156,6 +2163,7 @@ isa = PBXGroup; children = ( 18E059F7745769ABCA0F2A99 /* AppStoreProduct.swift */, + C66CFEB3004DF2C3C3DB44FF /* CustomStoreProduct.swift */, 8F17CFCD6B3B96A609A5B870 /* PaddleProduct.swift */, D38D3A69A26709BFB52C6A3A /* Product.swift */, 7106327DAD1C9044E4A57DD5 /* ProductStore.swift */, @@ -2677,6 +2685,7 @@ C800729D322B65BAD6FB3668 /* Request */ = { isa = PBXGroup; children = ( + 1FD32AF04F6FB9601759E529 /* CustomProductTests.swift */, 8A7140A8B0B006F2080D8915 /* PaywallLogicTests.swift */, EA72EEA12B281C235816CA44 /* StripeTrialEligibilityTests.swift */, C5DBF410102721FC5D440DF8 /* Mocks */, @@ -2715,7 +2724,7 @@ F5A959F1F550446C980DC5E5 /* StoreProductType.swift */, 6DE89E115B095A63FAC09719 /* StripeProductType.swift */, D1443B535E6E1D572A74733F /* SubscriptionPeriod.swift */, - 4998307C63AC04F4CC87F119 /* TestStoreProduct.swift */, + 4998307C63AC04F4CC87F119 /* APIStoreProduct.swift */, 10A0D5F687A5EF3EE39B4DCA /* Discount */, ); path = StoreProduct; @@ -3177,6 +3186,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 */, @@ -3340,6 +3350,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 */, @@ -3645,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 new file mode 100644 index 0000000000..d3976ee063 --- /dev/null +++ b/Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift @@ -0,0 +1,722 @@ +// +// 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 { + 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, + trialPeriodPrice: nil + ), + entitlements: [], + storefront: "USA" + ) + let testProduct = APIStoreProduct( + superwallProduct: superwallProduct, + entitlements: entitlements + ) + return StoreProduct(customProduct: testProduct) + } + + // 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 storeProduct = makeCustomStoreProduct(entitlements: []) + + #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 = APIStoreProduct( + superwallProduct: superwallProduct, + entitlements: [] + ) + let storeProduct = StoreProduct(testProduct: testProduct) + + #expect(!storeProduct.isCustomProduct) + } + + // MARK: - APIStoreProduct 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, + trialPeriodPrice: nil + ), + entitlements: [], + storefront: "USA" + ) + let testProduct = APIStoreProduct( + 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, + trialPeriodPrice: nil + ), + entitlements: [], + storefront: "USA" + ) + let testProduct = APIStoreProduct( + 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 = APIStoreProduct( + 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 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 coordinator = dependencyContainer.makePurchasingCoordinator() + #expect(!(await coordinator.isFreeTrialAvailable)) + } + + // 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 an APIStoreProduct with trial. + private func makeCustomStoreProductForTrialEligibility( + 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, + trialPeriodPrice: nil + ), + entitlements: [], + storefront: "USA" + ) + let testProduct = APIStoreProduct( + 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 = makeCustomStoreProductForTrialEligibility(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 = makeCustomStoreProductForTrialEligibility(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 = makeCustomStoreProductForTrialEligibility(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 = makeCustomStoreProductForTrialEligibility(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 = makeCustomStoreProductForTrialEligibility(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 = makeCustomStoreProductForTrialEligibility(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, + trialPeriodPrice: nil + ), + entitlements: [], + storefront: "USA" + ) + let testProduct = APIStoreProduct( + 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) + } +}