Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
4 changes: 4 additions & 0 deletions .github/workflows/build-platforms.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ jobs:
steps:
- name: Git Checkout
uses: actions/checkout@v3
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: xcodegen
uses: xavierLowmiller/xcodegen-action@1.2.3
- name: Build Mac Catalyst
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ jobs:
- name: Git Checkout
uses: actions/checkout@v3

- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable

- name: Remove Xcodeproj
run: |
rm -r SuperwallKit.xcodeproj
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/emerge-tools-upload.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ jobs:
- name: Git Checkout
uses: actions/checkout@v3

- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable

- name: xcodegen
uses: xavierLowmiller/xcodegen-action@1.1.2

Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ jobs:
steps:
- name: Git Checkout
uses: actions/checkout@v3
- name: Select Xcode
# macos-26 runners ship multiple Xcodes but `xcodebuild` defaults
# to whatever the runner image picked at provision time, which
# lags behind the latest installed version. The SK2 billing-plan
# work needs the iOS 26.4 SDK (`Product.SubscriptionInfo.PricingTerms`),
# so pin to the latest stable Xcode the runner has available.
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: xcodegen
uses: xavierLowmiller/xcodegen-action@1.2.3
- name: Run Tests
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

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

## 4.15.4

### Enhancements

- Adds support for annual subscriptions that are billed monthly.

## 4.15.3

### Fixes
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.15.4
"""
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 @@ import Foundation
@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.4+ subscription products with multiple
/// billing plans configured in App Store Connect.
@objc(SWKBillingPlanType)
public enum BillingPlanType: Int, Sendable {
case upFront
case monthly

enum StringValue: String, Codable {
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 @@ public final class AppStoreProduct: NSObject, Codable, Sendable {
)
)
}
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 @@ public final class AppStoreProduct: NSObject, Codable, Sendable {
}
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