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..4dc2f58641 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift @@ -20,10 +20,19 @@ import StoreKit struct SK2StoreProduct: StoreProductType { private let priceFormatterProvider = PriceFormatterProvider() 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 + entitlements: Set, + billingPlanType: AppStoreProduct.BillingPlanType? = nil ) { #if swift(<5.7) self._underlyingSK2Product = sk2Product @@ -31,6 +40,34 @@ struct SK2StoreProduct: StoreProductType { self.underlyingSK2Product = sk2Product #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( + _ billingPlanType: AppStoreProduct.BillingPlanType? + ) -> any StoreProductType { + return SK2StoreProduct( + sk2Product: underlyingSK2Product, + entitlements: entitlements, + billingPlanType: billingPlanType + ) } #if swift(<5.7) @@ -59,7 +96,47 @@ 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. + 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. + private var selectedSubscriptionPeriod: StoreKit.Product.SubscriptionPeriod? { + return cachedSelectedSubscriptionPeriod ?? underlyingSK2Product.subscription?.subscriptionPeriod + } + + #if compiler(>=6.3) + @available(iOS 26.4, *) + private static func findPricingTerm( + for billingPlanType: AppStoreProduct.BillingPlanType?, + in sk2Product: SK2Product + ) -> StoreKit.Product.SubscriptionInfo.PricingTerms? { + guard let plan = billingPlanType, + let terms = sk2Product.subscription?.pricingTerms else { + return nil + } + let target: StoreKit.Product.SubscriptionInfo.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 + } + return cachedHasMatchedTerm } /// A `NumberFormatter` for formatting computed prices (daily, weekly, monthly, yearly). @@ -73,7 +150,7 @@ struct SK2StoreProduct: StoreProductType { } var localizedSubscriptionPeriod: String { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + guard let subscriptionPeriod = selectedSubscriptionPeriod else { return "" } @@ -92,7 +169,7 @@ struct SK2StoreProduct: StoreProductType { } var period: String { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + guard let subscriptionPeriod = selectedSubscriptionPeriod else { return "" } @@ -129,7 +206,7 @@ struct SK2StoreProduct: StoreProductType { } var periodly: String { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + guard let subscriptionPeriod = selectedSubscriptionPeriod else { return "" } @@ -146,7 +223,7 @@ struct SK2StoreProduct: StoreProductType { } var periodWeeks: Int { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + guard let subscriptionPeriod = selectedSubscriptionPeriod else { return 0 } @@ -176,7 +253,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 +282,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 +311,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 +367,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 +583,7 @@ struct SK2StoreProduct: StoreProductType { } var price: Decimal { - underlyingSK2Product.price + selectedPrice } var isFamilyShareable: Bool { @@ -514,7 +591,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 +637,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..a3b0e38f04 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift @@ -45,6 +45,25 @@ 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 + /// (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? { + product.billingPlanType + } + + /// 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 +134,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 +394,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..81d25f947e 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,26 +70,29 @@ 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 + // 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 testProductsById: [String: StoreProduct] = [:] - for (id, product) in productsById { - testProductsById[id] = product - } - var productItems: [Product] = [] for original in paywall?.products ?? [] { - let id = original.id - if let product = testProductsById[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 { @@ -88,15 +100,34 @@ actor StoreKitManager { } } - testProductsById.forEach { id, product in - self.productsById[id] = product + // 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 } - 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 +142,7 @@ actor StoreKitManager { return nil } } ?? []) - let idsToFetch = paywallIDs + let idsToFetch = paywallAppleIDs .subtracting(byProductIDs) .union(byIdIDs) @@ -122,7 +153,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 +204,32 @@ 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. + // 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, + 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..0bb7c82fb2 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.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. + 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 + } + 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..d20cb21db7 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -63,7 +63,14 @@ 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 compositeProduct = await storeKitManager.productsByCompositeId[productId] + let fallbackProduct = await storeKitManager.productsById[productId] + let resolvedProduct = compositeProduct ?? fallbackProduct + 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/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 */, 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))) + } +}