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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub.

## 4.16.0

### Enhancements

- Added support for Apple's iOS 26 billing plan types on annual subscriptions. Configure two Superwall Products that share an Apple product identifier — one with `billingPlanType = UP_FRONT` and one with `MONTHLY` — to merchandise both plans on the same paywall. The selected plan is rendered with its own price/period from `Product.SubscriptionInfo.pricingTerms` and applied automatically at purchase via `.billingPlanType(...)`. Exposed `StoreProduct.billingPlanType` and `StoreProduct.isBillingPlanAvailable` for custom `PurchaseController` implementations. Two new attributes are surfaced to paywall templates: `billingPlanType` and `isBillingPlanAvailable`.

### Behaviour changes

- `PaywallInfo.productIds` now contains composite Product identifiers (e.g. `com.app.annual:MONTHLY`) for Superwall Products that opt into a billing plan. Apple product identifiers are still available via each entry's `Product.type.appStore.id`.

## 4.15.3

### Fixes
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.15.3
4.16.0
"""
20 changes: 19 additions & 1 deletion Sources/SuperwallKit/Models/Paywall/Paywall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ struct Paywall: Codable {
return PaywallLogic.getAppStoreProducts(from: products)
}

/// The deduplicated Apple product identifiers (not composite Product IDs)
/// of the paywall's App Store products. Used to fetch SK2 products, since
/// `StoreKit.Product.products(for:)` only accepts Apple product
/// identifiers — composite IDs like `com.app.annual:MONTHLY` would return
/// no products.
var appStoreProductIdentifiers: [String] {
var seen = Set<String>()
return appStoreProducts.compactMap { productItem in
guard case .appStore(let appStoreProduct) = productItem.type,
seen.insert(appStoreProduct.id).inserted else {
return nil
}
return appStoreProduct.id
}
}

/// The custom products associated with the paywall.
var customProducts: [Product] {
return PaywallLogic.getCustomProducts(from: products)
Expand All @@ -80,9 +96,11 @@ struct Paywall: Codable {
let introOfferEligibility: IntroOfferEligibility

var productIdsWithIntroOffers: [String] {
return productVariables?
let ids = productVariables?
.filter { $0.hasIntroOffer }
.map { $0.id } ?? []
var seen = Set<String>()
return ids.filter { seen.insert($0).inserted }
}

// MARK: - Added by client
Expand Down
50 changes: 49 additions & 1 deletion Sources/SuperwallKit/Models/Product/AppStoreProduct.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,64 @@
@objc(SWKAppStoreProduct)
@objcMembers
public final class AppStoreProduct: NSObject, Codable, Sendable {
/// The billing plan an App Store auto-renewing subscription product was
/// configured to use in the Superwall dashboard.
///
/// Two Superwall Products that share the same Apple `productIdentifier` but
/// configure different billing plans (e.g. annual up-front and
/// monthly-commitment annual) are merchandised as distinct entries on a
/// paywall. Available on iOS 26+ subscription products with multiple
/// billing plans configured in App Store Connect.
@objc(SWKBillingPlanType)
public enum BillingPlanType: Int, Sendable {
case upFront
case monthly

fileprivate enum StringValue: String, Codable {

Check warning on line 27 in Sources/SuperwallKit/Models/Product/AppStoreProduct.swift

View workflow job for this annotation

GitHub Actions / Package-SwiftLint

Strict Fileprivate Violation: `fileprivate` should be avoided (strict_fileprivate)
case upFront = "UP_FRONT"
case monthly = "MONTHLY"
}
}

/// The product identifier.
public let id: String

/// The product's store.
private let store: String

/// The billing plan configured on this Superwall Product. `nil` when the
/// Product doesn't opt into a specific billing plan; in that case purchases
/// proceed with whatever Apple's default plan is for the product.
public let billingPlanType: BillingPlanType?

enum CodingKeys: String, CodingKey {
case id = "productIdentifier"
case store
case billingPlanType
}

init(
id: String,
store: String = "APP_STORE"
store: String = "APP_STORE",
billingPlanType: BillingPlanType? = nil
) {
self.id = id
self.store = store
self.billingPlanType = billingPlanType
}

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)
if let billingPlanType = billingPlanType {
let raw: BillingPlanType.StringValue
switch billingPlanType {
case .upFront: raw = .upFront
case .monthly: raw = .monthly
}
try container.encode(raw, forKey: .billingPlanType)
}
}

public init(from decoder: any Decoder) throws {
Expand All @@ -48,6 +83,17 @@
)
)
}
if let raw = try container.decodeIfPresent(
BillingPlanType.StringValue.self,
forKey: .billingPlanType
) {
switch raw {
case .upFront: billingPlanType = .upFront
case .monthly: billingPlanType = .monthly
}
} else {
billingPlanType = nil
}
super.init()
}

Expand All @@ -57,12 +103,14 @@
}
return id == other.id
&& store == other.store
&& billingPlanType == other.billingPlanType
}

public override var hash: Int {
var hasher = Hasher()
hasher.combine(id)
hasher.combine(store)
hasher.combine(billingPlanType)
return hasher.finalize()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,19 @@

paywall.products = result.productItems

// Merge custom products into productsById so they appear in
// product variables and templating.
var mergedProductsById = result.productsById
// Merge custom products into the composite-keyed map so they appear in
// product variables and templating. Custom products have unique IDs,
// so composite ID == Apple ID for them and the lookup is direct.
var mergedProductsByCompositeId = result.productsByCompositeId
for product in customProducts {
if let cached = await storeKitManager.productsById[product.id] {
mergedProductsById[product.id] = cached
mergedProductsByCompositeId[product.id] = cached
}
}

let outcome = PaywallLogic.getProductVariables(
productItems: result.productItems,
productsById: mergedProductsById
productsById: mergedProductsByCompositeId
)
paywall.productVariables = outcome.productVariables

Expand Down Expand Up @@ -242,12 +243,24 @@
return paywall
}

// Check App Store products
let productsById = await storeKitManager.productsById
// Check App Store products. Lookup uses the composite-keyed map so
// billing-plan-specific Superwall Products resolve to the right clone.
// Falls back to the Apple-ID-keyed map for products loaded outside the
// paywall flow (e.g. preloaded overrides).
let productsByCompositeId = await storeKitManager.productsByCompositeId
let productsByAppleId = await storeKitManager.productsById
var isFreeTrialAvailable = false

for productItem in paywall.products {
guard let storeProduct = productsById[productItem.id] else {
let storeProduct: StoreProduct?
if let composite = productsByCompositeId[productItem.id] {
storeProduct = composite
} else if case .appStore(let appStoreProduct) = productItem.type {
storeProduct = productsByAppleId[appStoreProduct.id]
} else {
storeProduct = productsByAppleId[productItem.id]
}
guard let storeProduct = storeProduct else {
continue
}

Expand All @@ -272,11 +285,12 @@
}

// Check custom products for trial eligibility using the same entitlement-based
// approach as Stripe products.
// approach as Stripe products. Custom products are looked up by their
// unique ID in the Apple-ID-keyed map.
if !paywall.isFreeTrialAvailable {
paywall.isFreeTrialAvailable = await checkCustomTrialEligibility(
productItems: paywall.products,
productsById: productsById,
productsById: productsByAppleId,
introOfferEligibility: paywall.introOfferEligibility
)
}
Expand Down Expand Up @@ -398,7 +412,7 @@
Logger.debug(
logLevel: .warn,
scope: .productsManager,
message: "Stripe product \(stripeProduct.id) has trialDays > 0 but no entitlements — skipping trial eligibility check."

Check warning on line 415 in Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift

View workflow job for this annotation

GitHub Actions / Package-SwiftLint

Line Length Violation: Line should be 120 characters or less; currently it has 129 characters (line_length)
)
continue
}
Expand Down Expand Up @@ -435,7 +449,7 @@
Logger.debug(
logLevel: .warn,
scope: .productsManager,
message: "Custom product \(productItem.id) has a free trial but no entitlements — skipping trial eligibility check."

Check warning on line 452 in Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift

View workflow job for this annotation

GitHub Actions / Package-SwiftLint

Line Length Violation: Line should be 120 characters or less; currently it has 130 characters (line_length)
)
continue
}
Expand Down
Loading
Loading