From 0d17826bc44b0f4f70b846bac11a08f62cde0a43 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 14:07:55 +0000 Subject: [PATCH 01/12] Add Apple iOS 26 billing plan type support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Superwall Products that share an Apple product identifier — one configured for the up-front annual plan, one for the monthly-commitment plan — can now be merchandised together on a paywall. Pricing reads from StoreKit's pricingTerms and the selected plan rides into the purchase via the SK2 .billingPlanType(...) option on iOS 26+. Key changes: - AppStoreProduct gains a public, optional BillingPlanType decoded from the storeProduct payload's billingPlanType field. - Paywall keeps appStoreProductIds as composite Product IDs and adds a new appStoreProductIdentifiers list of deduped Apple IDs used to drive the StoreKit fetch. - StoreKitManager fetches by Apple ID, then builds a per-call productsByCompositeId map containing StoreProduct clones with their billing plan attached. Two clones can share an underlying SK2 product with independent billingPlanTypes. - StoreProduct exposes billingPlanType and isBillingPlanAvailable, plus a copyForCompositeProduct helper used by the manager. - SK2StoreProduct routes price, period, and computed per-period prices through the matching Product.SubscriptionInfo.PricingTerm when one is configured, falling back to the existing single-price path otherwise. - TransactionManager resolves purchases via the composite map first, falling back to the Apple-ID map. - ProductPurchaserSK2 inserts .billingPlanType(...) into the SK2 purchase option set on iOS 26+ when a plan is configured. Behaviour change: PaywallInfo.productIds now contains composite IDs (e.g. com.app.annual:MONTHLY) for Products that opt into a billing plan. The underlying Apple identifier is still reachable via the Product's type.appStore.id. https://claude.ai/code/session_01WoCsAoTAqQNKFbfsPYtQ8z --- CHANGELOG.md | 10 ++ Sources/SuperwallKit/Misc/Constants.swift | 2 +- .../SuperwallKit/Models/Paywall/Paywall.swift | 20 +++- .../Models/Product/AppStoreProduct.swift | 50 +++++++++- .../Operators/AddPaywallProducts.swift | 34 +++++-- .../StoreProduct/SK2StoreProduct.swift | 93 ++++++++++++++++--- .../Products/StoreProduct/StoreProduct.swift | 51 +++++++++- .../StoreProduct/StoreProductType.swift | 28 ++++++ .../StoreKit/StoreKitManager.swift | 59 +++++++++--- .../StoreKit 2/ProductPurchaserSK2.swift | 14 +++ .../Transactions/TransactionManager.swift | 8 +- SuperwallKit.podspec | 2 +- .../Models/PaywallBillingPlanTests.swift | 64 +++++++++++++ .../Models/Product/AppStoreProductTests.swift | 79 ++++++++++++++++ 14 files changed, 471 insertions(+), 43 deletions(-) create mode 100644 Tests/SuperwallKitTests/Models/PaywallBillingPlanTests.swift create mode 100644 Tests/SuperwallKitTests/Models/Product/AppStoreProductTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a8aa4c460..f7121df0a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 2326830fd3..5338fe7359 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.15.3 +4.16.0 """ diff --git a/Sources/SuperwallKit/Models/Paywall/Paywall.swift b/Sources/SuperwallKit/Models/Paywall/Paywall.swift index 3a41557e58..4b943d5221 100644 --- a/Sources/SuperwallKit/Models/Paywall/Paywall.swift +++ b/Sources/SuperwallKit/Models/Paywall/Paywall.swift @@ -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() + 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) @@ -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() + return ids.filter { seen.insert($0).inserted } } // MARK: - Added by client diff --git a/Sources/SuperwallKit/Models/Product/AppStoreProduct.swift b/Sources/SuperwallKit/Models/Product/AppStoreProduct.swift index 9ecc5768ec..255e161ce2 100644 --- a/Sources/SuperwallKit/Models/Product/AppStoreProduct.swift +++ b/Sources/SuperwallKit/Models/Product/AppStoreProduct.swift @@ -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+ 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 { + 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 { @@ -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() } @@ -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() } } diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift index c9a41aa6c3..4f4bb3b962 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift @@ -50,18 +50,19 @@ extension PaywallRequestManager { 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 @@ -242,12 +243,24 @@ extension PaywallRequestManager { 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 } @@ -272,11 +285,12 @@ extension PaywallRequestManager { } // 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 ) } diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift index f87c2bbc07..1f5db2c6df 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift @@ -20,10 +20,12 @@ import StoreKit struct SK2StoreProduct: StoreProductType { private let priceFormatterProvider = PriceFormatterProvider() let entitlements: Set + let billingPlanType: AppStoreProduct.BillingPlanType? init( sk2Product: SK2Product, - entitlements: Set + entitlements: Set, + billingPlanType: AppStoreProduct.BillingPlanType? = nil ) { #if swift(<5.7) self._underlyingSK2Product = sk2Product @@ -31,6 +33,17 @@ struct SK2StoreProduct: StoreProductType { self.underlyingSK2Product = sk2Product #endif self.entitlements = entitlements + self.billingPlanType = billingPlanType + } + + func withBillingPlanType( + _ billingPlanType: AppStoreProduct.BillingPlanType? + ) -> any StoreProductType { + return SK2StoreProduct( + sk2Product: underlyingSK2Product, + entitlements: entitlements, + billingPlanType: billingPlanType + ) } #if swift(<5.7) @@ -59,7 +72,61 @@ struct SK2StoreProduct: StoreProductType { } var localizedPrice: String { - return underlyingSK2Product.price.formatted(underlyingSK2Product.priceFormatStyle) + return selectedPrice.formatted(underlyingSK2Product.priceFormatStyle) + } + + /// The price to use for this product, routed through the selected billing + /// plan's pricing term when one is configured and available, otherwise the + /// underlying SK2 product's price. + fileprivate var selectedPrice: Decimal { + #if compiler(>=6.2) + if #available(iOS 26.0, *), + let term = selectedPricingTerm() { + return term.price + } + #endif + return underlyingSK2Product.price + } + + /// The subscription period to use for this product, routed through the + /// selected billing plan's pricing term when one is configured and + /// available, otherwise the underlying SK2 product's subscription period. + fileprivate var selectedSubscriptionPeriod: StoreKit.Product.SubscriptionPeriod? { + #if compiler(>=6.2) + if #available(iOS 26.0, *), + let term = selectedPricingTerm() { + return term.subscriptionPeriod + } + #endif + return underlyingSK2Product.subscription?.subscriptionPeriod + } + + #if compiler(>=6.2) + @available(iOS 26.0, *) + fileprivate func selectedPricingTerm() -> StoreKit.Product.SubscriptionInfo.PricingTerm? { + guard let plan = billingPlanType, + let terms = underlyingSK2Product.subscription?.pricingTerms else { + return nil + } + let target: StoreKit.Product.SubscriptionInfo.PricingTerm.BillingPlanType + switch plan { + case .upFront: target = .upFront + case .monthly: target = .monthly + } + return terms.first { $0.billingPlanType == target } + } + #endif + + var isBillingPlanAvailable: Bool { + guard billingPlanType != nil else { + return true + } + #if compiler(>=6.2) + if #available(iOS 26.0, *) { + return selectedPricingTerm() != nil + } + #endif + return false } /// A `NumberFormatter` for formatting computed prices (daily, weekly, monthly, yearly). @@ -73,7 +140,7 @@ struct SK2StoreProduct: StoreProductType { } var localizedSubscriptionPeriod: String { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + guard let subscriptionPeriod = selectedSubscriptionPeriod else { return "" } @@ -92,7 +159,7 @@ struct SK2StoreProduct: StoreProductType { } var period: String { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + guard let subscriptionPeriod = selectedSubscriptionPeriod else { return "" } @@ -129,7 +196,7 @@ struct SK2StoreProduct: StoreProductType { } var periodly: String { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + guard let subscriptionPeriod = selectedSubscriptionPeriod else { return "" } @@ -146,7 +213,7 @@ struct SK2StoreProduct: StoreProductType { } var periodWeeks: Int { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + guard let subscriptionPeriod = selectedSubscriptionPeriod else { return 0 } @@ -176,7 +243,7 @@ struct SK2StoreProduct: StoreProductType { } var periodMonths: Int { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + guard let subscriptionPeriod = selectedSubscriptionPeriod else { return 0 } let numberOfUnits = subscriptionPeriod.value @@ -205,7 +272,7 @@ struct SK2StoreProduct: StoreProductType { } var periodYears: Int { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + guard let subscriptionPeriod = selectedSubscriptionPeriod else { return 0 } let numberOfUnits = subscriptionPeriod.value @@ -234,7 +301,7 @@ struct SK2StoreProduct: StoreProductType { } var periodDays: Int { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + guard let subscriptionPeriod = selectedSubscriptionPeriod else { return 0 } let numberOfUnits = subscriptionPeriod.value @@ -290,7 +357,7 @@ struct SK2StoreProduct: StoreProductType { guard let subscriptionPeriod = subscriptionPeriod else { return "n/a" } - let result = perPeriod(subscriptionPeriod, underlyingSK2Product.price) + let result = perPeriod(subscriptionPeriod, selectedPrice) return priceFormatter.string(from: NSDecimalNumber(decimal: result)) ?? "n/a" } @@ -506,7 +573,7 @@ struct SK2StoreProduct: StoreProductType { } var price: Decimal { - underlyingSK2Product.price + selectedPrice } var isFamilyShareable: Bool { @@ -514,7 +581,7 @@ struct SK2StoreProduct: StoreProductType { } var subscriptionPeriod: SubscriptionPeriod? { - guard let skSubscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + guard let skSubscriptionPeriod = selectedSubscriptionPeriod else { return nil } return SubscriptionPeriod.from(sk2SubscriptionPeriod: skSubscriptionPeriod) @@ -560,9 +627,11 @@ struct SK2StoreProduct: StoreProductType { extension SK2StoreProduct: Hashable { static func == (lhs: SK2StoreProduct, rhs: SK2StoreProduct) -> Bool { return lhs.underlyingSK2Product == rhs.underlyingSK2Product + && lhs.billingPlanType == rhs.billingPlanType } func hash(into hasher: inout Hasher) { hasher.combine(self.underlyingSK2Product) + hasher.combine(self.billingPlanType) } } diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift index e63e58f001..fa0308808e 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift @@ -27,7 +27,7 @@ public typealias SK2Product = StoreKit.Product @objc(SWKStoreProduct) @objcMembers public final class StoreProduct: NSObject, StoreProductType, Sendable { - let product: StoreProductType + nonisolated(unsafe) var product: StoreProductType /// The intro offer eligibility token for this product, if available. /// @@ -45,6 +45,28 @@ public final class StoreProduct: NSObject, StoreProductType, Sendable { /// ``` public nonisolated(unsafe) var introOfferToken: IntroOfferToken? + /// The Apple billing plan configured on the Superwall Product wrapping this + /// store product, if any. + /// + /// Set by the SDK when building per-Product `StoreProduct`s for a paywall; + /// surfaced publicly so external `PurchaseController` implementations can + /// read it and pass `.billingPlanType(...)` to the SK2 purchase options + /// themselves. Only meaningful on iOS 26+. + /// + /// Setting this property re-routes price and period accessors through the + /// matching `pricingTerms` entry on the underlying SK2 product. + public var billingPlanType: AppStoreProduct.BillingPlanType? { + get { product.billingPlanType } + set { product = product.withBillingPlanType(newValue) } + } + + /// Whether the billing plan configured on the Superwall Product is + /// available on the current runtime (iOS 26+ in a supported region). + /// Returns `true` when no billing plan is configured. + public var isBillingPlanAvailable: Bool { + product.isBillingPlanAvailable + } + /// Whether this product is a custom product backed by the Superwall API. nonisolated(unsafe) var isCustomProduct = false @@ -115,10 +137,20 @@ public final class StoreProduct: NSObject, StoreProductType, Sendable { "languageCode": languageCode ?? "n/a", "currencyCode": currencyCode ?? "n/a", "currencySymbol": currencySymbol ?? "n/a", - "identifier": productIdentifier + "identifier": productIdentifier, + "billingPlanType": billingPlanTypeAttribute, + "isBillingPlanAvailable": "\(isBillingPlanAvailable)" ] } + private var billingPlanTypeAttribute: String { + switch billingPlanType { + case .upFront: return "UP_FRONT" + case .monthly: return "MONTHLY" + case .none: return "" + } + } + /// The JSON representation of ``attributes`` var attributesJson: JSON { return JSON(attributes) @@ -365,6 +397,21 @@ public final class StoreProduct: NSObject, StoreProductType, Sendable { return product as? StoreProduct ?? StoreProduct(product) } + /// Returns a copy of this `StoreProduct` with the given `billingPlanType` + /// attached. Used when the same underlying SK2 product is exposed by two + /// Superwall Products that differ only in their billing plan: each Product + /// gets its own `StoreProduct` clone so pricing and purchasing route + /// independently. + func copyForCompositeProduct( + billingPlanType: AppStoreProduct.BillingPlanType? + ) -> StoreProduct { + let copy = StoreProduct(product.withBillingPlanType(billingPlanType)) + copy.introOfferToken = self.introOfferToken + copy.isCustomProduct = self.isCustomProduct + copy.customTransactionId = self.customTransactionId + return copy + } + public convenience init( sk1Product: SK1Product ) { diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProductType.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProductType.swift index f55b36500f..140a73fe23 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProductType.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProductType.swift @@ -135,4 +135,32 @@ protocol StoreProductType: Sendable { /// - ``Purchases/eligiblePromotionalOffers(forProduct:)`` /// - ``StoreProduct/eligiblePromotionalOffers()`` var discounts: [StoreProductDiscount] { get } + + /// The billing plan configured on the Superwall Product wrapping this store + /// product. `nil` for non-App-Store products and for App Store products that + /// don't opt into a specific plan. + var billingPlanType: AppStoreProduct.BillingPlanType? { get } + + /// Whether the configured `billingPlanType` is available on the current + /// runtime (iOS 26+ in supported regions). Defaults to `true` when no plan + /// is configured. + var isBillingPlanAvailable: Bool { get } + + /// Returns a copy of `self` with the given `billingPlanType` attached. For + /// types that don't model billing plans (SK1, custom, etc.), returns `self`. + /// Returns the existential type so callers holding `any StoreProductType` + /// can re-assign through this method. + func withBillingPlanType( + _ billingPlanType: AppStoreProduct.BillingPlanType? + ) -> any StoreProductType +} + +extension StoreProductType { + var billingPlanType: AppStoreProduct.BillingPlanType? { nil } + var isBillingPlanAvailable: Bool { true } + func withBillingPlanType( + _ billingPlanType: AppStoreProduct.BillingPlanType? + ) -> any StoreProductType { + self + } } diff --git a/Sources/SuperwallKit/StoreKit/StoreKitManager.swift b/Sources/SuperwallKit/StoreKit/StoreKitManager.swift index 16e8f89dc9..a7cbdbaece 100644 --- a/Sources/SuperwallKit/StoreKit/StoreKitManager.swift +++ b/Sources/SuperwallKit/StoreKit/StoreKitManager.swift @@ -8,8 +8,17 @@ actor StoreKitManager { /// Retrieves products from storekit. private let productsManager: ProductsManager + /// Cached products keyed by their Apple product identifier. Custom and + /// test products live here too, keyed by their unique IDs. private(set) var productsById: [String: StoreProduct] = [:] + /// Cached products keyed by composite Product ID (`Product.id`). When two + /// Superwall Products share the same Apple product identifier but differ + /// in `billingPlanType`, both entries appear here, each wrapping a + /// `StoreProduct` clone with the matching billing plan attached. Built + /// from `productsById` during paywall product loading. + private(set) var productsByCompositeId: [String: StoreProduct] = [:] + func setProduct(_ product: StoreProduct, forIdentifier identifier: String) { productsById[identifier] = product } @@ -35,7 +44,7 @@ actor StoreKitManager { // Add the StoreProduct attributes for each product at its corresponding index paywall.appStoreProducts.forEach { productItem in - guard let storeProduct = output.productsById[productItem.id] else { + guard let storeProduct = output.productsByCompositeId[productItem.id] else { return } @@ -61,20 +70,23 @@ actor StoreKitManager { substituting substituteProductsByLabel: [String: ProductOverride]? = nil, isTestMode: Bool = false ) async throws -> ( - productsById: [String: StoreProduct], + productsByCompositeId: [String: StoreProduct], productItems: [Product] ) { // In test mode, use cached test products instead of fetching from StoreKit if isTestMode { - var testProductsById: [String: StoreProduct] = [:] + var testProductsByCompositeId: [String: StoreProduct] = [:] + // Test products use unique IDs (no Apple-ID sharing across composite + // IDs in test mode), so the composite map mirrors `productsById` for + // backwards compatibility with existing test fixtures. for (id, product) in productsById { - testProductsById[id] = product + testProductsByCompositeId[id] = product } var productItems: [Product] = [] for original in paywall?.products ?? [] { let id = original.id - if let product = testProductsById[id] { + if let product = testProductsByCompositeId[id] { productItems.append( Product( name: original.name, @@ -88,15 +100,18 @@ actor StoreKitManager { } } - testProductsById.forEach { id, product in - self.productsById[id] = product + testProductsByCompositeId.forEach { id, product in + self.productsByCompositeId[id] = product } - return (testProductsById, productItems) + return (testProductsByCompositeId, productItems) } - // 1. Compute fetch IDs = paywall IDs - byProduct IDs + byId IDs - let paywallIDs = Set(paywall?.appStoreProductIds ?? []) + // 1. Compute fetch IDs (Apple product identifiers, deduped) = paywall + // Apple IDs - byProduct IDs + byId IDs. We fetch by Apple ID, not by + // composite ID, because `StoreKit.Product.products(for:)` only + // accepts Apple product identifiers. + let paywallAppleIDs = Set(paywall?.appStoreProductIdentifiers ?? []) let byIdIDs: Set = Set(substituteProductsByLabel?.values.compactMap { if case .byId(let id) = $0 { return id @@ -111,7 +126,7 @@ actor StoreKitManager { return nil } } ?? []) - let idsToFetch = paywallIDs + let idsToFetch = paywallAppleIDs .subtracting(byProductIDs) .union(byIdIDs) @@ -122,7 +137,7 @@ actor StoreKitManager { placement: placement ) - // 3. Build lookup from identifier → StoreProduct + // 3. Build lookup from Apple identifier → StoreProduct var productsById = Dictionary( uniqueKeysWithValues: fetchedProducts.map { ($0.productIdentifier, $0) } ) @@ -173,12 +188,28 @@ actor StoreKitManager { } } - // 6. Cache in memory + // 6. Cache by Apple ID in memory productsById.forEach { id, product in self.productsById[id] = product } - return (productsById, productItems) + // 7. Build the composite-ID map. For each App Store Product on the + // paywall, clone the underlying StoreProduct and attach the slot's + // billing plan so price/period accessors route correctly and the + // purchase pipeline can pick the plan up later. Two composite entries + // sharing an Apple ID get two independent clones. + var productsByCompositeId: [String: StoreProduct] = [:] + for productItem in productItems { + guard case .appStore(let appStoreProduct) = productItem.type, + let base = productsById[appStoreProduct.id] else { + continue + } + let clone = base.copyForCompositeProduct(billingPlanType: appStoreProduct.billingPlanType) + productsByCompositeId[productItem.id] = clone + self.productsByCompositeId[productItem.id] = clone + } + + return (productsByCompositeId, productItems) } func preloadOverrides(_ overrides: [ProductOverride]) async { diff --git a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift index dce6c4f5e5..230dc40e8b 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift @@ -100,6 +100,20 @@ final class ProductPurchaserSK2: Purchasing { } #endif + #if compiler(>=6.2) + // Apply the configured Apple billing plan (iOS 26+). If the runtime is + // older or no plan is configured, the purchase proceeds with Apple's + // default plan. + if #available(iOS 26.0, *), let plan = product.billingPlanType { + let sk2Plan: StoreKit.Product.SubscriptionInfo.PricingTerm.BillingPlanType + switch plan { + case .upFront: sk2Plan = .upFront + case .monthly: sk2Plan = .monthly + } + options.insert(.billingPlanType(sk2Plan)) + } + #endif + let result: StoreKit.Product.PurchaseResult #if os(visionOS) diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index 17300ce016..de8d7fbb50 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -63,7 +63,13 @@ final class TransactionManager { switch purchaseSource { case .internal(let productId, _, _): - guard let storeProduct = await storeKitManager.productsById[productId] else { + // The JS bridge passes `Product.id` (composite when a billing plan is + // configured on the Product, otherwise equal to the Apple product ID). + // Try the composite-keyed map first; fall back to the Apple-ID map for + // custom and externally cached products that aren't part of a paywall. + let resolvedProduct = await storeKitManager.productsByCompositeId[productId] + ?? storeKitManager.productsById[productId] + guard let storeProduct = resolvedProduct else { Logger.debug( logLevel: .error, scope: .transactions, diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index d7ef7686ee..27d2d5647d 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.15.3" + s.version = "4.16.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/Tests/SuperwallKitTests/Models/PaywallBillingPlanTests.swift b/Tests/SuperwallKitTests/Models/PaywallBillingPlanTests.swift new file mode 100644 index 0000000000..1a8bbb563a --- /dev/null +++ b/Tests/SuperwallKitTests/Models/PaywallBillingPlanTests.swift @@ -0,0 +1,64 @@ +// +// PaywallBillingPlanTests.swift +// SuperwallKitTests +// + +@testable import SuperwallKit +import Testing +import Foundation + +// swiftlint:disable all + +struct PaywallBillingPlanTests { + @Test + func appStoreProductIdentifiers_dedupesAcrossBillingPlans() { + let monthly = Product( + name: "annual_monthly", + type: .appStore(AppStoreProduct(id: "com.app.annual", billingPlanType: .monthly)), + id: "com.app.annual:MONTHLY", + entitlements: [] + ) + let upfront = Product( + name: "annual_upfront", + type: .appStore(AppStoreProduct(id: "com.app.annual", billingPlanType: .upFront)), + id: "com.app.annual:UP_FRONT", + entitlements: [] + ) + let other = Product( + name: "monthly", + type: .appStore(AppStoreProduct(id: "com.app.monthly", billingPlanType: nil)), + id: "com.app.monthly", + entitlements: [] + ) + + var paywall = Paywall.stub() + paywall.products = [monthly, upfront, other] + + // Composite IDs (slot-level): all three distinct. + #expect(paywall.appStoreProductIds.sorted() == [ + "com.app.annual:MONTHLY", + "com.app.annual:UP_FRONT", + "com.app.monthly" + ]) + + // Apple product identifiers (for StoreKit fetch): deduped. + #expect(paywall.appStoreProductIdentifiers.sorted() == [ + "com.app.annual", + "com.app.monthly" + ]) + } + + @Test + func productIdsWithIntroOffers_isDeduped() { + var paywall = Paywall.stub() + paywall.productVariables = [ + ProductVariable(name: "a", attributes: JSON([String: Any]()), id: "com.app.annual", hasIntroOffer: true), + ProductVariable(name: "b", attributes: JSON([String: Any]()), id: "com.app.annual", hasIntroOffer: true), + ProductVariable(name: "c", attributes: JSON([String: Any]()), id: "com.app.weekly", hasIntroOffer: true), + ProductVariable(name: "d", attributes: JSON([String: Any]()), id: "com.app.no_trial", hasIntroOffer: false) + ] + + let ids = paywall.productIdsWithIntroOffers.sorted() + #expect(ids == ["com.app.annual", "com.app.weekly"]) + } +} diff --git a/Tests/SuperwallKitTests/Models/Product/AppStoreProductTests.swift b/Tests/SuperwallKitTests/Models/Product/AppStoreProductTests.swift new file mode 100644 index 0000000000..94ae8ee101 --- /dev/null +++ b/Tests/SuperwallKitTests/Models/Product/AppStoreProductTests.swift @@ -0,0 +1,79 @@ +// +// AppStoreProductTests.swift +// SuperwallKitTests +// + +@testable import SuperwallKit +import Testing +import Foundation + +// swiftlint:disable all + +struct AppStoreProductTests { + @Test + func decode_withMonthlyBillingPlan() throws { + let json = #""" + { + "productIdentifier": "com.app.annual", + "store": "APP_STORE", + "billingPlanType": "MONTHLY" + } + """#.data(using: .utf8)! + + let product = try JSONDecoder().decode(AppStoreProduct.self, from: json) + + #expect(product.id == "com.app.annual") + #expect(product.billingPlanType == .monthly) + } + + @Test + func decode_withUpFrontBillingPlan() throws { + let json = #""" + { + "productIdentifier": "com.app.annual", + "store": "APP_STORE", + "billingPlanType": "UP_FRONT" + } + """#.data(using: .utf8)! + + let product = try JSONDecoder().decode(AppStoreProduct.self, from: json) + + #expect(product.billingPlanType == .upFront) + } + + @Test + func decode_withoutBillingPlan_legacyCompatible() throws { + let json = #""" + { + "productIdentifier": "com.app.basic", + "store": "APP_STORE" + } + """#.data(using: .utf8)! + + let product = try JSONDecoder().decode(AppStoreProduct.self, from: json) + + #expect(product.id == "com.app.basic") + #expect(product.billingPlanType == nil) + } + + @Test + func encode_roundTripsBillingPlan() throws { + let original = AppStoreProduct(id: "com.app.annual", billingPlanType: .monthly) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(AppStoreProduct.self, from: data) + + #expect(decoded.id == original.id) + #expect(decoded.billingPlanType == original.billingPlanType) + } + + @Test + func equality_distinguishesByBillingPlan() { + let upfront = AppStoreProduct(id: "com.app.annual", billingPlanType: .upFront) + let monthly = AppStoreProduct(id: "com.app.annual", billingPlanType: .monthly) + let nilPlan = AppStoreProduct(id: "com.app.annual", billingPlanType: nil) + + #expect(!upfront.isEqual(monthly)) + #expect(!upfront.isEqual(nilPlan)) + #expect(upfront.isEqual(AppStoreProduct(id: "com.app.annual", billingPlanType: .upFront))) + } +} From 3bbdf5d349c589efd894bdc6621cea9f4ad9a2b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 21 May 2026 16:20:01 +0200 Subject: [PATCH 02/12] Fix iOS 26.4 StoreKit API name and availability for billing plans The PricingTerms type is plural in Apple's SDK (not PricingTerm), the BillingPlanType enum lives on Product.SubscriptionInfo (not nested under the term type), term fields are billingPrice / billingPeriod, and the whole API gates on iOS 26.4 rather than 26.0. Also separated the actor hops in TransactionManager so ?? doesn't try to put an actor-isolated read on the rhs autoclosure. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Products/StoreProduct/SK2StoreProduct.swift | 16 ++++++++-------- .../StoreKit 2/ProductPurchaserSK2.swift | 4 ++-- .../Transactions/TransactionManager.swift | 5 +++-- SuperwallKit.xcodeproj/project.pbxproj | 16 ++++++++++++++++ 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift index 1f5db2c6df..0c4406a829 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift @@ -80,9 +80,9 @@ struct SK2StoreProduct: StoreProductType { /// underlying SK2 product's price. fileprivate var selectedPrice: Decimal { #if compiler(>=6.2) - if #available(iOS 26.0, *), + if #available(iOS 26.4, *), let term = selectedPricingTerm() { - return term.price + return term.billingPrice } #endif return underlyingSK2Product.price @@ -93,22 +93,22 @@ struct SK2StoreProduct: StoreProductType { /// available, otherwise the underlying SK2 product's subscription period. fileprivate var selectedSubscriptionPeriod: StoreKit.Product.SubscriptionPeriod? { #if compiler(>=6.2) - if #available(iOS 26.0, *), + if #available(iOS 26.4, *), let term = selectedPricingTerm() { - return term.subscriptionPeriod + return term.billingPeriod } #endif return underlyingSK2Product.subscription?.subscriptionPeriod } #if compiler(>=6.2) - @available(iOS 26.0, *) - fileprivate func selectedPricingTerm() -> StoreKit.Product.SubscriptionInfo.PricingTerm? { + @available(iOS 26.4, *) + fileprivate func selectedPricingTerm() -> StoreKit.Product.SubscriptionInfo.PricingTerms? { guard let plan = billingPlanType, let terms = underlyingSK2Product.subscription?.pricingTerms else { return nil } - let target: StoreKit.Product.SubscriptionInfo.PricingTerm.BillingPlanType + let target: StoreKit.Product.SubscriptionInfo.BillingPlanType switch plan { case .upFront: target = .upFront case .monthly: target = .monthly @@ -122,7 +122,7 @@ struct SK2StoreProduct: StoreProductType { return true } #if compiler(>=6.2) - if #available(iOS 26.0, *) { + if #available(iOS 26.4, *) { return selectedPricingTerm() != nil } #endif diff --git a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift index 230dc40e8b..4daced6036 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift @@ -104,8 +104,8 @@ final class ProductPurchaserSK2: Purchasing { // Apply the configured Apple billing plan (iOS 26+). If the runtime is // older or no plan is configured, the purchase proceeds with Apple's // default plan. - if #available(iOS 26.0, *), let plan = product.billingPlanType { - let sk2Plan: StoreKit.Product.SubscriptionInfo.PricingTerm.BillingPlanType + if #available(iOS 26.4, *), let plan = product.billingPlanType { + let sk2Plan: StoreKit.Product.SubscriptionInfo.BillingPlanType switch plan { case .upFront: sk2Plan = .upFront case .monthly: sk2Plan = .monthly diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index de8d7fbb50..d20cb21db7 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -67,8 +67,9 @@ final class TransactionManager { // configured on the Product, otherwise equal to the Apple product ID). // Try the composite-keyed map first; fall back to the Apple-ID map for // custom and externally cached products that aren't part of a paywall. - let resolvedProduct = await storeKitManager.productsByCompositeId[productId] - ?? storeKitManager.productsById[productId] + let compositeProduct = await storeKitManager.productsByCompositeId[productId] + let fallbackProduct = await storeKitManager.productsById[productId] + let resolvedProduct = compositeProduct ?? fallbackProduct guard let storeProduct = resolvedProduct else { Logger.debug( logLevel: .error, diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index e1bb2f4e0e..c396f791ee 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -83,6 +83,7 @@ 225D6F5363B1520744EABD86 /* RedeemResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C339E7D08CDD7B1808AD4069 /* RedeemResponseTests.swift */; }; 22B3763157DF592D4E3B27A0 /* InAppReceipt+ASN1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E80C81546752BCF32016A27 /* InAppReceipt+ASN1.swift */; }; 234C1753A4606242CA765CA7 /* ManagedTriggerRuleOccurrence.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCFFBE357699F5CAAB803DA7 /* ManagedTriggerRuleOccurrence.swift */; }; + 236D81432A50D722A9702C38 /* PaywallBillingPlanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2955F8306C59091AEA338E0D /* PaywallBillingPlanTests.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 */; }; @@ -349,6 +350,7 @@ A8B37372F3F4ED9FAD76CE87 /* PostbackAssignmentWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F0D2AB91DA66490A73D1CB5 /* PostbackAssignmentWrapper.swift */; }; A9B924A1211117378743A534 /* MicrophonePermissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD045A5C4A47B1CA9365285 /* MicrophonePermissionTests.swift */; }; A9F9A35AEC72D17C7C15DAD4 /* PaywallArchiveManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB580546C647ED707C43FEC /* PaywallArchiveManager.swift */; }; + A9FC64A249BF2242BB526521 /* AppStoreProductTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4711FABAB250221629C47688 /* AppStoreProductTests.swift */; }; AA86FF87863EB7A7917969D3 /* PermissionHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7D0B6F781D32B2DCDBE689C /* PermissionHandling.swift */; }; AACC7BEE37DDDD7068A1E48C /* TransactionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646E6799FE4934BF76A06F34 /* TransactionManager.swift */; }; ABC17AE96AD396607E3CAB17 /* CoreDataStackMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E828EBAB18CCC0B236EF71D /* CoreDataStackMock.swift */; }; @@ -674,6 +676,7 @@ 2845A6B61C43D18F869D750E /* Model.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model.xcdatamodel; sourceTree = ""; }; 2888B05A273E9EB6E9382332 /* UIColor+Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Hex.swift"; sourceTree = ""; }; 28A1C8B0D79B8AC70ECF0CBB /* ConfigState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigState.swift; sourceTree = ""; }; + 2955F8306C59091AEA338E0D /* PaywallBillingPlanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallBillingPlanTests.swift; sourceTree = ""; }; 296A4AFE25C5E55DC5DD207D /* MockIntroductoryPeriod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockIntroductoryPeriod.swift; sourceTree = ""; }; 299441C3A6D3948BC5E0C741 /* PaywallInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallInfo.swift; sourceTree = ""; }; 299F91895EE88281B5ED8320 /* SWBounceButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWBounceButton.swift; sourceTree = ""; }; @@ -728,6 +731,7 @@ 460B6F98BADD9EC96A978E40 /* SWProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWProduct.swift; sourceTree = ""; }; 4634E3B868871DD24C2555F9 /* SWWebViewLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebViewLogic.swift; sourceTree = ""; }; 46D2598EB46E9A27E2BD5104 /* PreloadingDisabled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreloadingDisabled.swift; sourceTree = ""; }; + 4711FABAB250221629C47688 /* AppStoreProductTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreProductTests.swift; sourceTree = ""; }; 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 = ""; }; @@ -1511,9 +1515,11 @@ isa = PBXGroup; children = ( F0B300DB3367C74A42536395 /* CustomerInfoDecodingTests.swift */, + 2955F8306C59091AEA338E0D /* PaywallBillingPlanTests.swift */, 831F679BDAC779043091DB7E /* PaywallPresentationInfoTests.swift */, 9D7470637C9A0C7DF4924671 /* Assignment */, B26ABA960333F1BE7CAF7FAF /* Config */, + 7F6038B808DDEE213A11E8FF /* Product */, 195812D2590FDB0D3F2F36AE /* Web2App */, ); path = Models; @@ -2194,6 +2200,14 @@ path = Product; sourceTree = ""; }; + 7F6038B808DDEE213A11E8FF /* Product */ = { + isa = PBXGroup; + children = ( + 4711FABAB250221629C47688 /* AppStoreProductTests.swift */, + ); + path = Product; + sourceTree = ""; + }; 7FD9FD7878BD11B222C69278 /* Trackable Events */ = { isa = PBXGroup; children = ( @@ -3193,6 +3207,7 @@ 9B49485A1CFAC2621A89B150 /* AppSessionLogicTests.swift in Sources */, 1E81A71ADE8A5EAD9E609E1D /* AppSessionManagerMock.swift in Sources */, E2E0E2A82200943E73E3A92A /* AppSessionManagerTests.swift in Sources */, + A9FC64A249BF2242BB526521 /* AppStoreProductTests.swift in Sources */, 59685CE55D34FA6A96A8F890 /* AssignmentLogicTests.swift in Sources */, BC8A62869C7BACE6D0867195 /* AssignmentTests.swift in Sources */, 3CD2C23BAC2EA11174237785 /* AttributionTests.swift in Sources */, @@ -3256,6 +3271,7 @@ F0013E500B7F2113857F8161 /* NotificationSchedulerTests.swift in Sources */, A191F045B8A9EE2D4A3B757D /* OccurrenceLogicTests.swift in Sources */, 80A96673A17176DD5EFE1FA5 /* PageViewMessageTests.swift in Sources */, + 236D81432A50D722A9702C38 /* PaywallBillingPlanTests.swift in Sources */, 27E396F717A62BA4E0D98086 /* PaywallCacheLogicTests.swift in Sources */, 2205A0CC8F059B3D6231C603 /* PaywallLogicTests.swift in Sources */, 23CD6038DD65F057C81A412D /* PaywallManagerLogicTests.swift in Sources */, From fa8385a9e1c54dd06b7da4eff22bddbba00584f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 21 May 2026 17:28:43 +0200 Subject: [PATCH 03/12] Address billing plan review feedback - Make `StoreProduct.billingPlanType` read-only and `product` a `let` so mutation can only happen via `copyForCompositeProduct`, eliminating the data race on the previously `nonisolated(unsafe) var`. - In test mode, key `productsByCompositeId` by composite ID and clone the cached `StoreProduct` with the slot's billing plan so billing-plan paywalls work in test mode too. - Cache `selectedPrice` / `selectedSubscriptionPeriod` and the matched term flag on `SK2StoreProduct` at init so accessors don't re-scan `pricingTerms` on every property read. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../StoreProduct/SK2StoreProduct.swift | 56 +++++++++++-------- .../Products/StoreProduct/StoreProduct.swift | 17 +++--- .../StoreKit/StoreKitManager.swift | 42 +++++++++----- .../StoreKit 2/ProductPurchaserSK2.swift | 2 +- 4 files changed, 70 insertions(+), 47 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift index 0c4406a829..7b20baa070 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift @@ -22,6 +22,13 @@ struct SK2StoreProduct: StoreProductType { let entitlements: Set let billingPlanType: AppStoreProduct.BillingPlanType? + /// Resolved at init from `pricingTerms` to avoid iterating the term list on + /// every price/period accessor. `nil` when no billing plan is configured or + /// when no matching term exists for the current runtime. + private let cachedSelectedPrice: Decimal? + private let cachedSelectedSubscriptionPeriod: StoreKit.Product.SubscriptionPeriod? + private let cachedHasMatchedTerm: Bool + init( sk2Product: SK2Product, entitlements: Set, @@ -34,6 +41,23 @@ struct SK2StoreProduct: StoreProductType { #endif self.entitlements = entitlements self.billingPlanType = billingPlanType + + #if compiler(>=6.3) + if #available(iOS 26.4, *), + let term = Self.findPricingTerm(for: billingPlanType, in: sk2Product) { + self.cachedSelectedPrice = term.billingPrice + self.cachedSelectedSubscriptionPeriod = term.billingPeriod + self.cachedHasMatchedTerm = true + } else { + self.cachedSelectedPrice = nil + self.cachedSelectedSubscriptionPeriod = nil + self.cachedHasMatchedTerm = false + } + #else + self.cachedSelectedPrice = nil + self.cachedSelectedSubscriptionPeriod = nil + self.cachedHasMatchedTerm = false + #endif } func withBillingPlanType( @@ -79,33 +103,24 @@ struct SK2StoreProduct: StoreProductType { /// plan's pricing term when one is configured and available, otherwise the /// underlying SK2 product's price. fileprivate var selectedPrice: Decimal { - #if compiler(>=6.2) - if #available(iOS 26.4, *), - let term = selectedPricingTerm() { - return term.billingPrice - } - #endif - return underlyingSK2Product.price + return cachedSelectedPrice ?? underlyingSK2Product.price } /// The subscription period to use for this product, routed through the /// selected billing plan's pricing term when one is configured and /// available, otherwise the underlying SK2 product's subscription period. fileprivate var selectedSubscriptionPeriod: StoreKit.Product.SubscriptionPeriod? { - #if compiler(>=6.2) - if #available(iOS 26.4, *), - let term = selectedPricingTerm() { - return term.billingPeriod - } - #endif - return underlyingSK2Product.subscription?.subscriptionPeriod + return cachedSelectedSubscriptionPeriod ?? underlyingSK2Product.subscription?.subscriptionPeriod } - #if compiler(>=6.2) + #if compiler(>=6.3) @available(iOS 26.4, *) - fileprivate func selectedPricingTerm() -> StoreKit.Product.SubscriptionInfo.PricingTerms? { + private static func findPricingTerm( + for billingPlanType: AppStoreProduct.BillingPlanType?, + in sk2Product: SK2Product + ) -> StoreKit.Product.SubscriptionInfo.PricingTerms? { guard let plan = billingPlanType, - let terms = underlyingSK2Product.subscription?.pricingTerms else { + let terms = sk2Product.subscription?.pricingTerms else { return nil } let target: StoreKit.Product.SubscriptionInfo.BillingPlanType @@ -121,12 +136,7 @@ struct SK2StoreProduct: StoreProductType { guard billingPlanType != nil else { return true } - #if compiler(>=6.2) - if #available(iOS 26.4, *) { - return selectedPricingTerm() != nil - } - #endif - return false + return cachedHasMatchedTerm } /// A `NumberFormatter` for formatting computed prices (daily, weekly, monthly, yearly). diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift index fa0308808e..a3b0e38f04 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift @@ -27,7 +27,7 @@ public typealias SK2Product = StoreKit.Product @objc(SWKStoreProduct) @objcMembers public final class StoreProduct: NSObject, StoreProductType, Sendable { - nonisolated(unsafe) var product: StoreProductType + let product: StoreProductType /// The intro offer eligibility token for this product, if available. /// @@ -48,16 +48,13 @@ public final class StoreProduct: NSObject, StoreProductType, Sendable { /// The Apple billing plan configured on the Superwall Product wrapping this /// store product, if any. /// - /// Set by the SDK when building per-Product `StoreProduct`s for a paywall; - /// surfaced publicly so external `PurchaseController` implementations can - /// read it and pass `.billingPlanType(...)` to the SK2 purchase options - /// themselves. Only meaningful on iOS 26+. - /// - /// Setting this property re-routes price and period accessors through the - /// matching `pricingTerms` entry on the underlying SK2 product. + /// Set by the SDK when building per-Product `StoreProduct`s for a paywall + /// (via `copyForCompositeProduct`) and surfaced publicly read-only so + /// external `PurchaseController` implementations can pass + /// `.billingPlanType(...)` to the SK2 purchase options themselves. Only + /// meaningful on iOS 26+. public var billingPlanType: AppStoreProduct.BillingPlanType? { - get { product.billingPlanType } - set { product = product.withBillingPlanType(newValue) } + product.billingPlanType } /// Whether the billing plan configured on the Superwall Product is diff --git a/Sources/SuperwallKit/StoreKit/StoreKitManager.swift b/Sources/SuperwallKit/StoreKit/StoreKitManager.swift index a7cbdbaece..e9931b0374 100644 --- a/Sources/SuperwallKit/StoreKit/StoreKitManager.swift +++ b/Sources/SuperwallKit/StoreKit/StoreKitManager.swift @@ -73,26 +73,26 @@ actor StoreKitManager { productsByCompositeId: [String: StoreProduct], productItems: [Product] ) { - // In test mode, use cached test products instead of fetching from StoreKit + // In test mode, use cached test products instead of fetching from StoreKit. + // Cached test products are keyed by Apple identifier, so composite IDs that + // include a billing-plan suffix (e.g. `com.app.annual:MONTHLY`) must be + // resolved via the inner Apple ID. if isTestMode { - var testProductsByCompositeId: [String: StoreProduct] = [:] - // Test products use unique IDs (no Apple-ID sharing across composite - // IDs in test mode), so the composite map mirrors `productsById` for - // backwards compatibility with existing test fixtures. - for (id, product) in productsById { - testProductsByCompositeId[id] = product - } - var productItems: [Product] = [] for original in paywall?.products ?? [] { - let id = original.id - if let product = testProductsByCompositeId[id] { + let cached: StoreProduct? + if case .appStore(let appStoreProduct) = original.type { + cached = productsById[appStoreProduct.id] + } else { + cached = productsById[original.id] + } + if let cached = cached { productItems.append( Product( name: original.name, type: original.type, - id: id, - entitlements: product.entitlements + id: original.id, + entitlements: cached.entitlements ) ) } else { @@ -100,6 +100,22 @@ actor StoreKitManager { } } + // Build the composite-ID map. For App Store products, clone the cached + // StoreProduct with the slot's billing plan attached so billing-plan + // scenarios route correctly in test mode too. + var testProductsByCompositeId: [String: StoreProduct] = [:] + for productItem in productItems { + if case .appStore(let appStoreProduct) = productItem.type, + let base = productsById[appStoreProduct.id] { + let clone = base.copyForCompositeProduct( + billingPlanType: appStoreProduct.billingPlanType + ) + testProductsByCompositeId[productItem.id] = clone + } else if let base = productsById[productItem.id] { + testProductsByCompositeId[productItem.id] = base + } + } + testProductsByCompositeId.forEach { id, product in self.productsByCompositeId[id] = product } diff --git a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift index 4daced6036..0bb7c82fb2 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift @@ -100,7 +100,7 @@ final class ProductPurchaserSK2: Purchasing { } #endif - #if compiler(>=6.2) + #if compiler(>=6.3) // Apply the configured Apple billing plan (iOS 26+). If the runtime is // older or no plan is configured, the purchase proceeds with Apple's // default plan. From dc221506c73adb7cf5b7240388561567c1e1ca62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 21 May 2026 17:50:53 +0200 Subject: [PATCH 04/12] Tighten composite map lifetime and selected price access - Reset `productsByCompositeId` at the start of each non-test-mode fetch so orphaned billing-plan composite entries from prior paywalls don't accumulate across a session. - Demote `selectedPrice` / `selectedSubscriptionPeriod` from `fileprivate` to `private` since their only callers are inside the type itself. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../StoreKit/Products/StoreProduct/SK2StoreProduct.swift | 4 ++-- Sources/SuperwallKit/StoreKit/StoreKitManager.swift | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift index 7b20baa070..4dc2f58641 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift @@ -102,14 +102,14 @@ struct SK2StoreProduct: StoreProductType { /// The price to use for this product, routed through the selected billing /// plan's pricing term when one is configured and available, otherwise the /// underlying SK2 product's price. - fileprivate var selectedPrice: Decimal { + private var selectedPrice: Decimal { return cachedSelectedPrice ?? underlyingSK2Product.price } /// The subscription period to use for this product, routed through the /// selected billing plan's pricing term when one is configured and /// available, otherwise the underlying SK2 product's subscription period. - fileprivate var selectedSubscriptionPeriod: StoreKit.Product.SubscriptionPeriod? { + private var selectedSubscriptionPeriod: StoreKit.Product.SubscriptionPeriod? { return cachedSelectedSubscriptionPeriod ?? underlyingSK2Product.subscription?.subscriptionPeriod } diff --git a/Sources/SuperwallKit/StoreKit/StoreKitManager.swift b/Sources/SuperwallKit/StoreKit/StoreKitManager.swift index e9931b0374..81d25f947e 100644 --- a/Sources/SuperwallKit/StoreKit/StoreKitManager.swift +++ b/Sources/SuperwallKit/StoreKit/StoreKitManager.swift @@ -214,6 +214,10 @@ actor StoreKitManager { // billing plan so price/period accessors route correctly and the // purchase pipeline can pick the plan up later. Two composite entries // sharing an Apple ID get two independent clones. + // Reset the actor-level map so composite entries from a previous + // paywall don't linger (composite IDs encode a paywall-specific + // billing-plan suffix, unlike stable Apple IDs in `productsById`). + self.productsByCompositeId = [:] var productsByCompositeId: [String: StoreProduct] = [:] for productItem in productItems { guard case .appStore(let appStoreProduct) = productItem.type, From c58ec13fe90ae47dc1f3a5ddc35cd1fffb0e1edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 27 May 2026 14:34:09 +0200 Subject: [PATCH 05/12] Route MONTHLY plan through commitmentInfo so the product still reads as annual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MONTHLY billing plan on an annual subscription means "the user pays monthly for 12 months totaling the annual price." Apple exposes two sides: - `billingPrice` / `billingPeriod`: the per-cycle charge ($3.33 / month) - `commitmentInfo.price` / `commitmentInfo.period`: the full annual commitment ($39.96 / year) I had cached the *billing* side at init, which made every period accessor (period, periodly, periodWeeks, etc.) report month and every price accessor report $3.33. That contradicts the paywall designer's intent — they configured an annual product and expect the paywall to read as annual. The dashboard preview shows year/$2.49, the iOS simulator was showing month/$3.33 — confusing. Switch the cache to `commitmentInfo.price` / `commitmentInfo.period`. Now an annual MONTHLY product surfaces as: - period: year - yearlyPrice: $39.96 - monthlyPrice: $3.33 (computed: $39.96 / 12) - weeklyPrice / dailyPrice: derived from $39.96 / year For UP_FRONT, billingPrice == commitmentInfo.price and billingPeriod == commitmentInfo.period, so its behavior doesn't change. Per-cycle data (`$3.33 / month`) is still reachable through Apple's SK2 API for paywalls that want to render "Billed $X/month with 12-month commitment" copy; a future change can expose `billingPrice` and `billingPeriod` as dedicated paywall attributes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Products/StoreProduct/SK2StoreProduct.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift index 4dc2f58641..52ec08e828 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift @@ -45,8 +45,17 @@ struct SK2StoreProduct: StoreProductType { #if compiler(>=6.3) if #available(iOS 26.4, *), let term = Self.findPricingTerm(for: billingPlanType, in: sk2Product) { - self.cachedSelectedPrice = term.billingPrice - self.cachedSelectedSubscriptionPeriod = term.billingPeriod + // Route through `commitmentInfo` rather than `billingPrice` / + // `billingPeriod` so an annual product configured with the MONTHLY + // billing plan still surfaces as an annual product to the paywall + // (period = year, price = annual total). The per-cycle data + // (`$3.33` / `month`) is still available via SK2's own accessors if + // a paywall needs to render "Billed $X/month with 12-month + // commitment" copy. For the UP_FRONT plan, billingPrice equals + // commitmentInfo.price and billingPeriod equals commitmentInfo.period, + // so the choice doesn't change UP_FRONT behavior. + self.cachedSelectedPrice = term.commitmentInfo.price + self.cachedSelectedSubscriptionPeriod = term.commitmentInfo.period self.cachedHasMatchedTerm = true } else { self.cachedSelectedPrice = nil From d7dc14035a8a5cb21f537821bc3a14a5db28dbe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 27 May 2026 14:40:45 +0200 Subject: [PATCH 06/12] Compute commitment total from billingPrice, route intro offer per plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs the simulator surfaced once the MONTHLY plan started flowing through: 1. `price` showed $3.33 / `yearlyPrice` showed $3.33 for an annual MONTHLY product whose Commitment Total is $39.96. Apple's `commitmentInfo.price` empirically returns the per-cycle amount, not the total — even though `commitmentInfo.period` correctly returns `year`. Compute the commitment total ourselves as `billingPrice × cycles in commitment`, where cycles is derived by normalizing billing + commitment periods to a common day count. For UP_FRONT (where billingPeriod == commitmentInfo.period), cycles = 1 and the result equals billingPrice — no behavior change. 2. `hasFreeTrial` / `trialPeriodDays` / `trialPeriodPrice` and friends all read from `underlyingSK2Product.subscription?.introductoryOffer`, which is Apple's default-plan field. On iOS 26.4+ each PricingTerm carries its own `subscriptionOffers` array — the MONTHLY plan can have a different intro offer than the UP_FRONT plan. Cache the plan-specific intro offer at init via `term[offers: .introductory].first` and add a `selectedIntroductoryOffer` accessor that falls back to the legacy field. Replace every legacy read with the new accessor. Both changes apply only when a billing plan is configured AND iOS 26.4+ is available; legacy behavior is preserved otherwise. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../StoreProduct/SK2StoreProduct.swift | 95 +++++++++++++++---- 1 file changed, 75 insertions(+), 20 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift index 52ec08e828..d7fba6dcb8 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift @@ -27,6 +27,7 @@ struct SK2StoreProduct: StoreProductType { /// when no matching term exists for the current runtime. private let cachedSelectedPrice: Decimal? private let cachedSelectedSubscriptionPeriod: StoreKit.Product.SubscriptionPeriod? + private let cachedSelectedIntroductoryOffer: StoreKit.Product.SubscriptionOffer? private let cachedHasMatchedTerm: Bool init( @@ -45,30 +46,75 @@ struct SK2StoreProduct: StoreProductType { #if compiler(>=6.3) if #available(iOS 26.4, *), let term = Self.findPricingTerm(for: billingPlanType, in: sk2Product) { - // Route through `commitmentInfo` rather than `billingPrice` / - // `billingPeriod` so an annual product configured with the MONTHLY - // billing plan still surfaces as an annual product to the paywall - // (period = year, price = annual total). The per-cycle data - // (`$3.33` / `month`) is still available via SK2's own accessors if - // a paywall needs to render "Billed $X/month with 12-month - // commitment" copy. For the UP_FRONT plan, billingPrice equals - // commitmentInfo.price and billingPeriod equals commitmentInfo.period, - // so the choice doesn't change UP_FRONT behavior. - self.cachedSelectedPrice = term.commitmentInfo.price + // Use the commitment *period* (= year for an annual MONTHLY product) + // so the paywall reads as the underlying product (not its billing + // cycle), then compute the total commitment price ourselves as + // `billingPrice × cycles in commitment`. Apple's `commitmentInfo.price` + // empirically returns the per-cycle amount, not the total — computing + // from billingPrice + cycle count is robust either way and matches + // the merchandising semantics the dashboard already enforces. + // For UP_FRONT, billingPeriod == commitmentInfo.period so cycles = 1 + // and the result equals billingPrice — no change. + let cycles = Self.cyclesInCommitment( + billingPeriod: term.billingPeriod, + commitmentPeriod: term.commitmentInfo.period + ) + self.cachedSelectedPrice = term.billingPrice * Decimal(cycles) self.cachedSelectedSubscriptionPeriod = term.commitmentInfo.period + // Intro offers are per-billing-plan on iOS 26.4+: each `PricingTerms` + // has its own `subscriptionOffers` array. Pull the plan-specific + // introductory offer (if any) so the paywall surfaces the trial / + // intro pricing configured against this billing plan rather than + // the underlying SK2 product's default-plan offer. + self.cachedSelectedIntroductoryOffer = + term[offers: .introductory].first self.cachedHasMatchedTerm = true } else { self.cachedSelectedPrice = nil self.cachedSelectedSubscriptionPeriod = nil + self.cachedSelectedIntroductoryOffer = nil self.cachedHasMatchedTerm = false } #else self.cachedSelectedPrice = nil self.cachedSelectedSubscriptionPeriod = nil + self.cachedSelectedIntroductoryOffer = nil self.cachedHasMatchedTerm = false #endif } + /// Counts how many billing cycles fit into one commitment period (e.g. 12 + /// for monthly billing on a yearly commitment). Normalizes both periods + /// to a common day count rather than relying on Apple's + /// `commitmentInfo.price`, which empirically returns the per-cycle amount. + /// Both arguments are typealiases for `StoreKit.Product.SubscriptionPeriod` + /// even though Apple names them differently in `BillingPeriod` / + /// `CommitmentInfo.period`. + private static func cyclesInCommitment( + billingPeriod: StoreKit.Product.SubscriptionPeriod, + commitmentPeriod: StoreKit.Product.SubscriptionPeriod + ) -> Int { + let billingDays = daysIn(unit: billingPeriod.unit, value: billingPeriod.value) + let commitmentDays = daysIn(unit: commitmentPeriod.unit, value: commitmentPeriod.value) + guard billingDays > 0 else { return 1 } + let raw = Double(commitmentDays) / Double(billingDays) + let rounded = Int(raw.rounded()) + return max(rounded, 1) + } + + private static func daysIn( + unit: StoreKit.Product.SubscriptionPeriod.Unit, + value: Int + ) -> Int { + switch unit { + case .day: return value + case .week: return value * 7 + case .month: return value * 30 + case .year: return value * 365 + @unknown default: return value * 30 + } + } + func withBillingPlanType( _ billingPlanType: AppStoreProduct.BillingPlanType? ) -> any StoreProductType { @@ -122,6 +168,15 @@ struct SK2StoreProduct: StoreProductType { return cachedSelectedSubscriptionPeriod ?? underlyingSK2Product.subscription?.subscriptionPeriod } + /// The introductory offer for this product, routed through the matched + /// billing-plan pricing term's `subscriptionOffers` when a plan is + /// configured (so the MONTHLY plan's intro offer is surfaced rather than + /// the SK2 default plan's). Falls back to the legacy + /// `subscription?.introductoryOffer` otherwise. + private var selectedIntroductoryOffer: StoreKit.Product.SubscriptionOffer? { + return cachedSelectedIntroductoryOffer ?? selectedIntroductoryOffer + } + #if compiler(>=6.3) @available(iOS 26.4, *) private static func findPricingTerm( @@ -381,11 +436,11 @@ struct SK2StoreProduct: StoreProductType { } var hasFreeTrial: Bool { - return underlyingSK2Product.subscription?.introductoryOffer != nil + return selectedIntroductoryOffer != nil } var trialPeriodEndDate: Date? { - guard let trialPeriod = underlyingSK2Product.subscription?.introductoryOffer?.period else { + guard let trialPeriod = selectedIntroductoryOffer?.period else { return nil } let numberOfUnits = trialPeriod.value @@ -429,7 +484,7 @@ struct SK2StoreProduct: StoreProductType { } var trialPeriodDays: Int { - guard let trialPeriod = underlyingSK2Product.subscription?.introductoryOffer?.period else { + guard let trialPeriod = selectedIntroductoryOffer?.period else { return 0 } @@ -459,7 +514,7 @@ struct SK2StoreProduct: StoreProductType { } var trialPeriodWeeks: Int { - guard let trialPeriod = underlyingSK2Product.subscription?.introductoryOffer?.period else { + guard let trialPeriod = selectedIntroductoryOffer?.period else { return 0 } let numberOfUnits = trialPeriod.value @@ -488,7 +543,7 @@ struct SK2StoreProduct: StoreProductType { } var trialPeriodMonths: Int { - guard let trialPeriod = underlyingSK2Product.subscription?.introductoryOffer?.period else { + guard let trialPeriod = selectedIntroductoryOffer?.period else { return 0 } let numberOfUnits = trialPeriod.value @@ -517,7 +572,7 @@ struct SK2StoreProduct: StoreProductType { } var trialPeriodYears: Int { - guard let trialPeriod = underlyingSK2Product.subscription?.introductoryOffer?.period else { + guard let trialPeriod = selectedIntroductoryOffer?.period else { return 0 } let numberOfUnits = trialPeriod.value @@ -546,7 +601,7 @@ struct SK2StoreProduct: StoreProductType { } var trialPeriodText: String { - guard let trialPeriod = underlyingSK2Product.subscription?.introductoryOffer?.period else { + guard let trialPeriod = selectedIntroductoryOffer?.period else { return "" } @@ -607,7 +662,7 @@ struct SK2StoreProduct: StoreProductType { } var introductoryDiscount: StoreProductDiscount? { - underlyingSK2Product.subscription?.introductoryOffer + selectedIntroductoryOffer .flatMap { StoreProductDiscount(sk2Discount: $0, currencyCode: currencyCode) } } @@ -617,7 +672,7 @@ struct SK2StoreProduct: StoreProductType { } var trialPeriodPrice: Decimal { - underlyingSK2Product.subscription?.introductoryOffer?.price ?? 0.00 + selectedIntroductoryOffer?.price ?? 0.00 } func trialPeriodPricePerUnit(_ unit: SubscriptionPeriod.Unit) -> String { @@ -634,7 +689,7 @@ struct SK2StoreProduct: StoreProductType { } var localizedTrialPeriodPrice: String { - guard let price = underlyingSK2Product.subscription?.introductoryOffer?.price else { + guard let price = selectedIntroductoryOffer?.price else { return Decimal(0).formatted(underlyingSK2Product.priceFormatStyle) } return price.formatted(underlyingSK2Product.priceFormatStyle) From b87ac578694937306676f36d984af5778a157738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 27 May 2026 14:42:51 +0200 Subject: [PATCH 07/12] Fix infinite recursion in selectedIntroductoryOffer fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit's `replace_all` swapped every `underlyingSK2Product.subscription?.introductoryOffer` for `selectedIntroductoryOffer` — including inside the accessor's own fallback, which made the property recurse on itself and crash with `EXC_BAD_ACCESS` from stack overflow on any product that hit the legacy path (no billing plan configured). Restore the literal field reference inside the accessor only; external consumers continue to go through `selectedIntroductoryOffer` as intended. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../StoreKit/Products/StoreProduct/SK2StoreProduct.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift index d7fba6dcb8..1456711865 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift @@ -174,7 +174,7 @@ struct SK2StoreProduct: StoreProductType { /// the SK2 default plan's). Falls back to the legacy /// `subscription?.introductoryOffer` otherwise. private var selectedIntroductoryOffer: StoreKit.Product.SubscriptionOffer? { - return cachedSelectedIntroductoryOffer ?? selectedIntroductoryOffer + return cachedSelectedIntroductoryOffer ?? underlyingSK2Product.subscription?.introductoryOffer } #if compiler(>=6.3) From 6983338a991581affbaf288775091d497aa5f896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 27 May 2026 14:47:14 +0200 Subject: [PATCH 08/12] Don't fall back to legacy intro offer when the chosen plan has none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cachedSelectedIntroductoryOffer` is nil for two distinct reasons: 1. No billing plan was matched (legacy product, older OS, unsupported region). The legacy `subscription?.introductoryOffer` is the right fallback here. 2. A billing plan *was* matched, but that plan's `subscriptionOffers` doesn't include an introductory offer (e.g. UPFRONT plan has a free trial configured in ASC, MONTHLY plan does not). The previous accessor coalesced both with `??`, which meant case (2) silently re-surfaced the default-plan's offer — a MONTHLY purchase would display the UPFRONT plan's trial. Bug shown clearly when swapping the UPFRONT plan's intro from "Free 1 Month" to "Pay-as-you-go $0.99 / 1 Year": the simulator (purchasing MONTHLY) flipped right along with it, even though MONTHLY had no intro offer in ASC. Gate the fallback on `cachedHasMatchedTerm` so the accessor honors a nil cached value when the plan matched. Legacy products still fall through to the underlying SK2 introductoryOffer. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Products/StoreProduct/SK2StoreProduct.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift index 1456711865..58ff571d69 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift @@ -171,10 +171,16 @@ struct SK2StoreProduct: StoreProductType { /// The introductory offer for this product, routed through the matched /// billing-plan pricing term's `subscriptionOffers` when a plan is /// configured (so the MONTHLY plan's intro offer is surfaced rather than - /// the SK2 default plan's). Falls back to the legacy - /// `subscription?.introductoryOffer` otherwise. + /// the SK2 default plan's, and an *absent* intro offer on the chosen + /// plan is honored even if a different plan has one). Falls back to the + /// legacy `subscription?.introductoryOffer` only when no plan was + /// matched — `cachedHasMatchedTerm` gates that explicitly so a nil + /// cached offer doesn't silently re-surface the default plan's offer. private var selectedIntroductoryOffer: StoreKit.Product.SubscriptionOffer? { - return cachedSelectedIntroductoryOffer ?? underlyingSK2Product.subscription?.introductoryOffer + if cachedHasMatchedTerm { + return cachedSelectedIntroductoryOffer + } + return underlyingSK2Product.subscription?.introductoryOffer } #if compiler(>=6.3) From f567d0a08bfc7e2439655bce323a24045eed68da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 27 May 2026 15:26:48 +0200 Subject: [PATCH 09/12] Style: positive if over negated guard in isBillingPlanAvailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the existing repo preference (per memory.md) of using `if X == nil { return blah }` rather than `guard X != nil else { ... }` — the negation is harder to read and the positive form is consistent with how the rest of this file handles nil checks. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../StoreKit/Products/StoreProduct/SK2StoreProduct.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift index 58ff571d69..00bf3ac0ae 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift @@ -203,7 +203,7 @@ struct SK2StoreProduct: StoreProductType { #endif var isBillingPlanAvailable: Bool { - guard billingPlanType != nil else { + if billingPlanType == nil { return true } return cachedHasMatchedTerm From ee241aef0ee44521c66f6add3b179dac9be8b19c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 27 May 2026 15:34:44 +0200 Subject: [PATCH 10/12] billingPlanType reflects what's charged, not dashboard intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Product's configured billing plan isn't honored on the device (older OS, US/Singapore for MONTHLY, etc.), the SDK falls back to Apple's default plan — price/period accessors already reflect that. Surfacing the dashboard's intended billing plan in `StoreProduct.billingPlanType` regardless meant paywall templates could render "Subscribe with 12-month commitment for $X/month" over a purchase that would actually charge $Y upfront. Designers would have had to manually gate every reference to `billingPlanType` on `isBillingPlanAvailable` or risk a price-mismatch surprise. Make `billingPlanType` reflect what will be charged: return the configured value only when `isBillingPlanAvailable` is true, else nil. Single source of truth for the template, and the purchase path inherits the gate too — `ProductPurchaserSK2` already guards `if let plan = product.billingPlanType` before inserting the SK2 option, so the unavailable case now silently uses Apple's default. `isBillingPlanAvailable` keeps its diagnostic role: distinguishes "no plan configured" from "configured but unavailable here." Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Products/StoreProduct/StoreProduct.swift | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift index a3b0e38f04..3253e5b812 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift @@ -45,16 +45,29 @@ public final class StoreProduct: NSObject, StoreProductType, Sendable { /// ``` public nonisolated(unsafe) var introOfferToken: IntroOfferToken? - /// The Apple billing plan configured on the Superwall Product wrapping this - /// store product, if any. + /// The Apple billing plan **that will actually be applied at purchase** + /// on this device. Returns `nil` when no plan is configured on the + /// Superwall Product OR when the configured plan isn't honored by Apple + /// here (older OS, US/Singapore for MONTHLY, etc.) — in those cases the + /// purchase falls back to Apple's default plan and so does the value + /// surfaced here. /// - /// Set by the SDK when building per-Product `StoreProduct`s for a paywall - /// (via `copyForCompositeProduct`) and surfaced publicly read-only so - /// external `PurchaseController` implementations can pass - /// `.billingPlanType(...)` to the SK2 purchase options themselves. Only - /// meaningful on iOS 26+. + /// Paywalls / `PurchaseController` implementations can key off this + /// directly without separately gating on `isBillingPlanAvailable` — the + /// value matches what the price/period accessors are already reporting, + /// so copy like "Subscribe with monthly commitment" won't render over + /// an upfront-billed purchase. + /// + /// The dashboard's *intent* (e.g. "this slot was configured MONTHLY") + /// is preserved via the slot's `swCompositeProductId` if it's ever + /// needed for analytics; use `isBillingPlanAvailable` to distinguish + /// "no plan configured" from "configured but unavailable on this + /// device." public var billingPlanType: AppStoreProduct.BillingPlanType? { - product.billingPlanType + if product.isBillingPlanAvailable { + return product.billingPlanType + } + return nil } /// Whether the billing plan configured on the Superwall Product is From 02ef2327f57b0634a24b00ef562443c33f17a49f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 27 May 2026 16:03:27 +0200 Subject: [PATCH 11/12] ci: pin Xcode to latest-stable on macos-26 runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SK2 billing-plan work depends on `Product.SubscriptionInfo.PricingTerms`, which only exists in the iOS 26.4 SDK and newer. GitHub's `macos-26` runner image ships multiple Xcodes side by side but `xcodebuild` defaults to whichever was selected when the image was provisioned — usually an older 26.x. That's why tests pass locally (where 26.4+ is selected) but CI fails with `'PricingTerms' is not a member type`. Add `maxim-lobanov/setup-xcode@v1` with `latest-stable` to every macos-26 workflow so they all build against the newest Xcode the runner has installed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build-platforms.yml | 4 ++++ .github/workflows/docs.yml | 5 +++++ .github/workflows/emerge-tools-upload.yml | 5 +++++ .github/workflows/tests.yml | 9 +++++++++ 4 files changed, 23 insertions(+) diff --git a/.github/workflows/build-platforms.yml b/.github/workflows/build-platforms.yml index 90a3ce45e1..b2648023c6 100644 --- a/.github/workflows/build-platforms.yml +++ b/.github/workflows/build-platforms.yml @@ -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 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 16aacf4aa6..c75217eea8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -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 diff --git a/.github/workflows/emerge-tools-upload.yml b/.github/workflows/emerge-tools-upload.yml index b6eeced687..ece58e910b 100644 --- a/.github/workflows/emerge-tools-upload.yml +++ b/.github/workflows/emerge-tools-upload.yml @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 62c6368af8..32da8f3859 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 From f9541c93ae9389ce3a6a04d6ab9efe563486cbfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 27 May 2026 16:09:34 +0200 Subject: [PATCH 12/12] =?UTF-8?q?isBillingPlanAvailable=20false=20for=20le?= =?UTF-8?q?gacy=20products=20=E2=80=94=20gateable=20on=20its=20own?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `SK2StoreProduct.isBillingPlanAvailable` returned `true` when no billing plan was configured. That meant a paywall template gating UI on `isBillingPlanAvailable` (e.g. "render the 12-month-commitment copy when there's a plan to use") would also fire for legacy products that have no plan at all — exactly the misuse the attribute is meant to prevent. Flip the legacy case to `false` so the attribute reads cleanly as "is there a billing plan to use right now?": - No plan configured (legacy): `false` - Plan configured + honored by device (iOS 26.4+, supported region): `true` - Plan configured + not honored (older OS, US/Singapore for MONTHLY): `false` — `cachedHasMatchedTerm` already returns false here. Paywall designers can now write `{{#if isBillingPlanAvailable }}…{{/if}}` without separately checking `billingPlanType` — a single source of truth. The dashboard preview's hard-coded `"true"` is being aligned in the paywall-next companion commit so editor and runtime agree. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Products/StoreProduct/SK2StoreProduct.swift | 7 ++++++- .../Products/StoreProduct/StoreProduct.swift | 13 ++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift index 00bf3ac0ae..91e7a61da4 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift @@ -203,8 +203,13 @@ struct SK2StoreProduct: StoreProductType { #endif var isBillingPlanAvailable: Bool { + // "Is there a billing plan to use?" — `true` only when a plan is + // configured AND the device exposes a matching pricing term. Legacy + // products (no plan configured) return `false` so paywall templates + // can gate billing-plan-specific copy on `isBillingPlanAvailable` + // without separately checking `billingPlanType`. if billingPlanType == nil { - return true + return false } return cachedHasMatchedTerm } diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift index 3253e5b812..27640ec8e0 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift @@ -70,9 +70,16 @@ public final class StoreProduct: NSObject, StoreProductType, Sendable { return nil } - /// Whether the billing plan configured on the Superwall Product is - /// available on the current runtime (iOS 26+ in a supported region). - /// Returns `true` when no billing plan is configured. + /// Whether there's an Apple billing plan to use for this product on + /// the current runtime — i.e. a non-null `billingPlanType` is + /// configured on the Superwall Product AND the matching pricing term + /// is exposed in `Product.SubscriptionInfo.pricingTerms` (iOS 26.4+ + /// in a supported region). + /// + /// Returns `false` for legacy products with no billing plan + /// configured, so paywall templates can gate billing-plan-specific + /// copy on this value alone without separately checking + /// `billingPlanType`. public var isBillingPlanAvailable: Bool { product.isBillingPlanAvailable }