Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Sources/SuperwallKit/Config/ConfigManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions Sources/SuperwallKit/Dependencies/DependencyContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Sources/SuperwallKit/Dependencies/FactoryProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion Sources/SuperwallKit/Misc/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ let sdkVersion = """
*/

let sdkVersion = """
4.14.2
4.15.0
"""
5 changes: 5 additions & 0 deletions Sources/SuperwallKit/Models/Paywall/Paywall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
68 changes: 68 additions & 0 deletions Sources/SuperwallKit/Models/Product/CustomStoreProduct.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
74 changes: 56 additions & 18 deletions Sources/SuperwallKit/Models/Product/Product.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
)
}
}
Expand All @@ -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)
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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
}
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions Sources/SuperwallKit/Models/Product/ProductStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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"
}

Expand All @@ -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)
}
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
5 changes: 5 additions & 0 deletions Sources/SuperwallKit/Network/V2ProductsResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public enum SuperwallProductPlatform: String, Decodable, Sendable {
case stripe
case paddle
case superwall
case custom
}

/// Price information for a product.
Expand All @@ -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"
}
}

Expand Down
Loading
Loading