Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
aa839af
Merge pull request #439 from superwall/develop
yusuftor Feb 24, 2026
280fd1a
Merge pull request #447 from superwall/develop
yusuftor Mar 11, 2026
d9f7e50
Add custom store product support for external purchase controllers
yusuftor Mar 13, 2026
4bfce3b
Always regenerate custom transaction ID on each purchase attempt
yusuftor Mar 13, 2026
fba87e8
Move .custom case after .other in ProductStore to preserve raw values
yusuftor Mar 13, 2026
9bcf13a
Fix tests
yusuftor Mar 18, 2026
6e55786
Correct free trial behaviour, add formUnion override
yusuftor Mar 18, 2026
20c2015
Update version, add to changelog
yusuftor Mar 18, 2026
0c919a1
Merge branch 'develop' into yusuf/custom-store-product
yusuftor Mar 18, 2026
9ebc779
small fixes
yusuftor Mar 18, 2026
4978f36
Update CustomProductTests.swift
yusuftor Mar 18, 2026
273a9db
Add trial period price to test/custom products
yusuftor Mar 18, 2026
7844038
test fixes
yusuftor Mar 18, 2026
6eb8fd1
Update AddPaywallProducts.swift
yusuftor Mar 18, 2026
7468306
Merge pull request #458 from superwall/develop
yusuftor Apr 5, 2026
6179521
bug fix
yusuftor Apr 16, 2026
d48e236
Align custom product price formatting with SK2 and guard against miss…
yusuftor Apr 16, 2026
5b11417
Update CHANGELOG.md
yusuftor Apr 16, 2026
5827071
Merge branch 'master' into yusuf/custom-store-product
yusuftor Apr 16, 2026
fe03098
Update CHANGELOG.md
yusuftor Apr 16, 2026
fdf2247
Update APIStoreProduct.swift
yusuftor Apr 16, 2026
182d223
Derive custom product price locale from storefront and add explicit p…
yusuftor Apr 17, 2026
bdd20de
Remove diagnostic [SW-CUSTOM] debugPrints
yusuftor Apr 17, 2026
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

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 using the `PurchaseController`.
- Adds `formUnion` override when unioning sets of `Entitlement` objects.

### Fixes

- Fixes issue where test mode products had trial price data missing.

## 4.14.2

### Enhancements
Expand Down
3 changes: 2 additions & 1 deletion Sources/SuperwallKit/Config/ConfigLogic.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,8 @@
from config: Config
) -> [String: Set<Entitlement>] {
return Dictionary(
uniqueKeysWithValues: config.products.map { ($0.id, $0.entitlements) }
config.products.map { ($0.id, $0.entitlements) },
uniquingKeysWith: { $0.union($1) }

Check warning on line 310 in Sources/SuperwallKit/Config/ConfigLogic.swift

View workflow job for this annotation

GitHub Actions / Package-SwiftLint

Trailing Closure Violation: Trailing closure syntax should be used whenever possible (trailing_closure)
)
}
}
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
)
Comment thread
yusuftor marked this conversation as resolved.
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