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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a8aa4c460..20272126c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## 4.15.4 + +### Enhancements + +- Adds support for annual subscriptions that are billed monthly. + ## 4.15.3 ### Fixes diff --git a/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 140cce312f..4597ebe526 100644 --- a/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/RevenueCat/purchases-ios.git", "state": { "branch": null, - "revision": "6b95744e70f1edc43f89f2b522b0832ddfdd41a1", - "version": "5.73.0" + "revision": "3bbaa4a88d74c863ee5bc4295b2fc628323eff0d", + "version": "5.75.0" } }, { diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 2326830fd3..e926212ec0 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.15.4 """ 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..e1ccd51a64 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.4+ subscription products with multiple + /// billing plans configured in App Store Connect. + @objc(SWKBillingPlanType) + public enum BillingPlanType: Int, Sendable { + case upFront + case monthly + + enum StringValue: String, Codable { + case upFront = "UP_FRONT" + case monthly = "MONTHLY" + } + } + /// The product identifier. public let id: String /// The product's store. private let store: String + /// The billing plan configured on this Superwall Product. `nil` when the + /// Product doesn't opt into a specific billing plan; in that case purchases + /// proceed with whatever Apple's default plan is for the product. + public let billingPlanType: BillingPlanType? + enum CodingKeys: String, CodingKey { case id = "productIdentifier" case store + case billingPlanType } init( id: String, - store: String = "APP_STORE" + store: String = "APP_STORE", + billingPlanType: BillingPlanType? = nil ) { self.id = id self.store = store + self.billingPlanType = billingPlanType } public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encode(store, forKey: .store) + if let billingPlanType = billingPlanType { + let raw: BillingPlanType.StringValue + switch billingPlanType { + case .upFront: raw = .upFront + case .monthly: raw = .monthly + } + try container.encode(raw, forKey: .billingPlanType) + } } public init(from decoder: any Decoder) throws { @@ -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..91e7a61da4 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/SK2StoreProduct.swift @@ -20,10 +20,20 @@ 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 cachedSelectedIntroductoryOffer: StoreKit.Product.SubscriptionOffer? + private let cachedHasMatchedTerm: Bool init( sk2Product: SK2Product, - entitlements: Set + entitlements: Set, + billingPlanType: AppStoreProduct.BillingPlanType? = nil ) { #if swift(<5.7) self._underlyingSK2Product = sk2Product @@ -31,6 +41,88 @@ 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) { + // 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 { + return SK2StoreProduct( + sk2Product: underlyingSK2Product, + entitlements: entitlements, + billingPlanType: billingPlanType + ) } #if swift(<5.7) @@ -59,7 +151,67 @@ 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 + } + + /// 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, 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? { + if cachedHasMatchedTerm { + return cachedSelectedIntroductoryOffer + } + return underlyingSK2Product.subscription?.introductoryOffer + } + + #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 { + // "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 false + } + return cachedHasMatchedTerm } /// A `NumberFormatter` for formatting computed prices (daily, weekly, monthly, yearly). @@ -73,7 +225,7 @@ struct SK2StoreProduct: StoreProductType { } var localizedSubscriptionPeriod: String { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + guard let subscriptionPeriod = selectedSubscriptionPeriod else { return "" } @@ -92,7 +244,7 @@ struct SK2StoreProduct: StoreProductType { } var period: String { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + guard let subscriptionPeriod = selectedSubscriptionPeriod else { return "" } @@ -129,7 +281,7 @@ struct SK2StoreProduct: StoreProductType { } var periodly: String { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + guard let subscriptionPeriod = selectedSubscriptionPeriod else { return "" } @@ -146,7 +298,7 @@ struct SK2StoreProduct: StoreProductType { } var periodWeeks: Int { - guard let subscriptionPeriod = underlyingSK2Product.subscription?.subscriptionPeriod else { + guard let subscriptionPeriod = selectedSubscriptionPeriod else { return 0 } @@ -176,7 +328,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 +357,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 +386,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,16 +442,16 @@ 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" } 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 @@ -343,7 +495,7 @@ struct SK2StoreProduct: StoreProductType { } var trialPeriodDays: Int { - guard let trialPeriod = underlyingSK2Product.subscription?.introductoryOffer?.period else { + guard let trialPeriod = selectedIntroductoryOffer?.period else { return 0 } @@ -373,7 +525,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 @@ -402,7 +554,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 @@ -431,7 +583,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 @@ -460,7 +612,7 @@ struct SK2StoreProduct: StoreProductType { } var trialPeriodText: String { - guard let trialPeriod = underlyingSK2Product.subscription?.introductoryOffer?.period else { + guard let trialPeriod = selectedIntroductoryOffer?.period else { return "" } @@ -506,7 +658,7 @@ struct SK2StoreProduct: StoreProductType { } var price: Decimal { - underlyingSK2Product.price + selectedPrice } var isFamilyShareable: Bool { @@ -514,14 +666,14 @@ 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) } var introductoryDiscount: StoreProductDiscount? { - underlyingSK2Product.subscription?.introductoryOffer + selectedIntroductoryOffer .flatMap { StoreProductDiscount(sk2Discount: $0, currencyCode: currencyCode) } } @@ -531,7 +683,7 @@ struct SK2StoreProduct: StoreProductType { } var trialPeriodPrice: Decimal { - underlyingSK2Product.subscription?.introductoryOffer?.price ?? 0.00 + selectedIntroductoryOffer?.price ?? 0.00 } func trialPeriodPricePerUnit(_ unit: SubscriptionPeriod.Unit) -> String { @@ -548,7 +700,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) @@ -560,9 +712,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..2cb565fb4a 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift @@ -11,7 +11,7 @@ // // Created by Andrés Boedo on 7/16/21. // Updated by Yusuf Tör from Superwall on 11/8/22. -// swiftlint:disable file_length +// swiftlint:disable file_length type_body_length import Foundation import StoreKit @@ -45,6 +45,45 @@ public final class StoreProduct: NSObject, StoreProductType, Sendable { /// ``` public nonisolated(unsafe) var introOfferToken: IntroOfferToken? + /// 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. + /// + /// 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? { + if product.isBillingPlanAvailable { + return product.billingPlanType + } + return nil + } + + /// 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 + } + /// Whether this product is a custom product backed by the Superwall API. nonisolated(unsafe) var isCustomProduct = false @@ -115,10 +154,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 +414,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..80e3958992 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProductType.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProductType.swift @@ -135,4 +135,33 @@ 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 `false` when no plan + /// is configured, so paywall templates can gate billing-plan copy on this + /// value alone. + 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 { false } + func withBillingPlanType( + _ billingPlanType: AppStoreProduct.BillingPlanType? + ) -> any StoreProductType { + self + } } diff --git a/Sources/SuperwallKit/StoreKit/StoreKitManager.swift b/Sources/SuperwallKit/StoreKit/StoreKitManager.swift index 16e8f89dc9..4e602fdd3c 100644 --- a/Sources/SuperwallKit/StoreKit/StoreKitManager.swift +++ b/Sources/SuperwallKit/StoreKit/StoreKitManager.swift @@ -8,11 +8,32 @@ 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 } + + /// Resolves a cached `StoreProduct` for a `Product.id`. + /// + /// Prefers the composite-keyed map (used when a billing plan is configured + /// on the Product) and falls back to the Apple-ID map for custom and + /// externally cached products that aren't part of a paywall. Both lookups + /// happen in a single actor hop and the fallback is only consulted when the + /// composite lookup misses. + func product(withId id: String) -> StoreProduct? { + return productsByCompositeId[id] ?? productsById[id] + } + private struct ProductProcessingResult { let productIdsToLoad: Set let substituteProductsById: [String: StoreProduct] @@ -35,7 +56,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 +82,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 +112,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 +154,7 @@ actor StoreKitManager { return nil } } ?? []) - let idsToFetch = paywallIDs + let idsToFetch = paywallAppleIDs .subtracting(byProductIDs) .union(byIdIDs) @@ -122,7 +165,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 +216,38 @@ 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. + // + // Accumulate into the actor-level map rather than resetting it. A + // composite ID (e.g. `com.app.annual:MONTHLY`) maps deterministically + // to a clone of stable base product data plus a fixed billing plan, so + // entries can't go stale across paywalls. Resetting would wipe every + // other paywall's entries, which breaks preloading: all paywalls are + // preloaded into a shared cache and a preloaded paywall is never + // re-resolved when presented, so the purchase pipeline would fail to + // find its billing-plan products. The map is keyed by a finite set of + // Apple-ID × billing-plan combinations, so it stays small. + 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..94e4ffee01 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/Purchasing/StoreKit 2/ProductPurchaserSK2.swift @@ -82,6 +82,7 @@ final class ProductPurchaserSK2: Purchasing { } } + // swiftlint:disable:next function_body_length func purchase(product: StoreProduct) async -> PurchaseResult { guard let sk2Product = product.sk2Product else { return .cancelled @@ -100,6 +101,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..6ac11909a3 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). + // `product(withId:)` resolves against the composite-keyed map first and + // falls back to the Apple-ID map for custom and externally cached + // products that aren't part of a paywall. + let resolvedProduct = await storeKitManager.product(withId: productId) + guard let storeProduct = resolvedProduct else { Logger.debug( logLevel: .error, scope: .transactions, diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index d7ef7686ee..8167b28f93 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.15.4" 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..b7d0592453 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 */; }; @@ -371,6 +373,7 @@ B10030CC414C2C341487F4B8 /* PaywallViewControllerDelegateAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 990461F7A9B2F3ED62B3A628 /* PaywallViewControllerDelegateAdapter.swift */; }; B146C134ABE092C3C9ACADEC /* PurchaseResult+Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A524F7AAE90E48C3B8D7E99A /* PurchaseResult+Internal.swift */; }; B15607185B9E4229C6C4F240 /* SK2StoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B96E2A1A289D96267EC0BC /* SK2StoreTransaction.swift */; }; + B162BE92B3568078BC0ADD1B /* StoreProductBillingPlanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F98E1C9554F6AFAECF9B3430 /* StoreProductBillingPlanTests.swift */; }; B294572426111EC04F225289 /* MockExternalPurchaseControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9C9132109020FA03D1D5C7 /* MockExternalPurchaseControllerFactory.swift */; }; B2AB1E9283FDE2D544C8BCA8 /* MockReceiptData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39B81D88316F06C0C2757F10 /* MockReceiptData.swift */; }; B2AC4436371BC96FAA4FB5B3 /* CustomCallbackRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CFC75AD1252D05D2033D7B0 /* CustomCallbackRegistryTests.swift */; }; @@ -674,6 +677,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 +732,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 = ""; }; @@ -1157,6 +1162,7 @@ F85ED994DEC92BB90ACC6AC2 /* TrackingResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackingResult.swift; sourceTree = ""; }; F9098101E599AEB01521FE89 /* DebugViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugViewController.swift; sourceTree = ""; }; F96ABDCA3686C7F1CAF41B03 /* en_GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en_GB; path = en_GB.lproj/Localizable.strings; sourceTree = ""; }; + F98E1C9554F6AFAECF9B3430 /* StoreProductBillingPlanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreProductBillingPlanTests.swift; sourceTree = ""; }; F98F66F7AA2F9F3E96849D8A /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; F9D2422F9742D74360FB716B /* TaskRetryingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRetryingTests.swift; sourceTree = ""; }; F9D538EA68425ECB218BA3CA /* AdServicesAttributionAttempts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdServicesAttributionAttempts.swift; sourceTree = ""; }; @@ -1511,9 +1517,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 +2202,14 @@ path = Product; sourceTree = ""; }; + 7F6038B808DDEE213A11E8FF /* Product */ = { + isa = PBXGroup; + children = ( + 4711FABAB250221629C47688 /* AppStoreProductTests.swift */, + ); + path = Product; + sourceTree = ""; + }; 7FD9FD7878BD11B222C69278 /* Trackable Events */ = { isa = PBXGroup; children = ( @@ -2331,6 +2347,7 @@ children = ( D6340ACDA40937ACAC66FA3D /* EntitlementPriorityTests.swift */, BC580BF1CC720ECBC4E68A28 /* SK2PriceFormatRoundingTests.swift */, + F98E1C9554F6AFAECF9B3430 /* StoreProductBillingPlanTests.swift */, 2CF1F5EAC9C4E384EBBE5EA9 /* SubscriptionPeriodPriceTests.swift */, ); path = StoreProduct; @@ -3193,6 +3210,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 +3274,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 */, @@ -3288,6 +3307,7 @@ 713A1F9D9861C6A1E5EB9174 /* StorageTests.swift in Sources */, 701B1B586B6C1E3F0B3AF560 /* StoreKitManagerTests.swift in Sources */, F5F8C2E02A057DA15C2936AB /* StorePresentationObjectsOperatorTests.swift in Sources */, + B162BE92B3568078BC0ADD1B /* StoreProductBillingPlanTests.swift in Sources */, 5E51E14716E29C9B88B8A6F2 /* StripeTrialEligibilityTests.swift in Sources */, 097719E21BBD153BA6FD6785 /* SubscriptionPeriodPriceTests.swift in Sources */, E9F892ABB9BDA85F4794E3CF /* SubscriptionStatusResolutionTests.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))) + } +} diff --git a/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/StoreProductBillingPlanTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/StoreProductBillingPlanTests.swift new file mode 100644 index 0000000000..a658113460 --- /dev/null +++ b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/StoreProductBillingPlanTests.swift @@ -0,0 +1,32 @@ +// +// StoreProductBillingPlanTests.swift +// SuperwallKitTests +// +// swiftlint:disable all + +import Foundation +import Testing +@testable import SuperwallKit + +/// Regression tests for the `StoreProductType.isBillingPlanAvailable` default. +/// +/// The protocol default previously returned `true`, so SK1 / custom products — +/// which have no billing plan — reported `isBillingPlanAvailable == true`, +/// contradicting `SK2StoreProduct` (which returns `false` when no plan is +/// configured) and the documented `StoreProduct` contract. That made paywall +/// templates gating billing-plan copy behave differently in test mode (SK1) +/// versus production (SK2). The default is now `false`. +@Suite("StoreProduct billing plan availability") +struct StoreProductBillingPlanTests { + @Test("SK1 products report no billing plan available") + func sk1ProductReportsNoBillingPlanAvailable() { + let product = StoreProduct( + sk1Product: MockSkProduct(productIdentifier: "com.app.annual"), + entitlements: [] + ) + + #expect(product.isBillingPlanAvailable == false) + #expect(product.billingPlanType == nil) + #expect(product.attributes["isBillingPlanAvailable"] == "false") + } +} diff --git a/Tests/SuperwallKitTests/StoreKit/StoreKitManagerTests.swift b/Tests/SuperwallKitTests/StoreKit/StoreKitManagerTests.swift index 3abfef77f7..1a32a23a76 100644 --- a/Tests/SuperwallKitTests/StoreKit/StoreKitManagerTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/StoreKitManagerTests.swift @@ -1,25 +1,26 @@ // // StoreKitManagerTests.swift -// +// // // Created by Yusuf Tör on 01/09/2022. // // swiftlint:disable all -import XCTest +import Testing +import Foundation @testable import SuperwallKit import StoreKit -class StoreKitManagerTests: XCTestCase { +@Suite("StoreKitManager Tests") +@MainActor +struct StoreKitManagerTests { let dependencyContainer = DependencyContainer() - lazy var purchaseController: AutomaticPurchaseController = { - return AutomaticPurchaseController(factory: dependencyContainer, entitlementsInfo: dependencyContainer.entitlementsInfo) - }() - func test_getProducts_primaryProduct() async { + @Test("getProducts returns the substituted primary product") + func getProducts_primaryProduct() async throws { let primary = MockSkProduct(productIdentifier: "abc") let entitlements: Set = [.stub()] - + // Mock the products fetcher to return empty set since we're substituting all products let productsResult: Result, Error> = .success([]) let productsFetcher = ProductsFetcherSK1Mock( @@ -34,34 +35,31 @@ class StoreKitManagerTests: XCTestCase { let manager = StoreKitManager( productsManager: productsManager ) - + let substituteProducts = [ "primary": ProductOverride.byProduct(StoreProduct(sk1Product: primary, entitlements: entitlements)) ] let paywall = Paywall.stub() .setting(\.products, to: [.init(name: "primary", type: .appStore(.init(id: "xyz")), id: "xyz", entitlements: [])]) - do { - let (productsById, products) = try await manager.getProducts( - forPaywall: paywall, - placement: nil, - substituting: substituteProducts - ) - XCTAssertEqual(productsById[primary.productIdentifier]?.sk1Product, primary) - XCTAssertTrue(products.contains { $0.id == primary.productIdentifier }) - XCTAssertTrue(products.contains { $0.name == "primary" }) - XCTAssertTrue(products.contains { $0.entitlements == entitlements }) - - XCTAssertEqual(products.count, 1) - } catch { - XCTFail("couldn't get products") - } + + let (productsById, products) = try await manager.getProducts( + forPaywall: paywall, + placement: nil, + substituting: substituteProducts + ) + #expect(productsById[primary.productIdentifier]?.sk1Product == primary) + #expect(products.contains { $0.id == primary.productIdentifier }) + #expect(products.contains { $0.name == "primary" }) + #expect(products.contains { $0.entitlements == entitlements }) + #expect(products.count == 1) } - func test_getProducts_primaryAndTertiaryProduct() async { + @Test("getProducts returns the substituted primary and tertiary products") + func getProducts_primaryAndTertiaryProduct() async throws { let primary = MockSkProduct(productIdentifier: "abc") let primaryEntitlements: Set = [.stub()] let tertiary = MockSkProduct(productIdentifier: "def") - + // Mock the products fetcher to return empty set since we're substituting all products let productsResult: Result, Error> = .success([]) let productsFetcher = ProductsFetcherSK1Mock( @@ -88,32 +86,28 @@ class StoreKitManagerTests: XCTestCase { .init(name: "tertiary", type: .appStore(.init(id: "ghi")), id: "ghi", entitlements: [.stub()]), ]) - do { - let (productsById, products) = try await manager.getProducts( - forPaywall: paywall, - placement: nil, - substituting: substituteProducts - ) - XCTAssertEqual(productsById[primary.productIdentifier]?.sk1Product, primary) - XCTAssertTrue(products.contains { $0.id == primary.productIdentifier }) - XCTAssertTrue(products.contains { $0.name == "primary" }) - XCTAssertTrue(products.contains { $0.entitlements == primaryEntitlements }) - XCTAssertTrue(products.contains { $0.objcAdapter.store == .appStore }) - XCTAssertTrue(products.contains { $0.id == tertiary.productIdentifier }) - XCTAssertTrue(products.contains { $0.name == "tertiary" }) - XCTAssertEqual(products.count, 2) - - XCTAssertEqual(productsById[tertiary.productIdentifier]?.sk1Product, tertiary) - } catch { - XCTFail("couldn't get products") - } + let (productsById, products) = try await manager.getProducts( + forPaywall: paywall, + placement: nil, + substituting: substituteProducts + ) + #expect(productsById[primary.productIdentifier]?.sk1Product == primary) + #expect(products.contains { $0.id == primary.productIdentifier }) + #expect(products.contains { $0.name == "primary" }) + #expect(products.contains { $0.entitlements == primaryEntitlements }) + #expect(products.contains { $0.objcAdapter.store == .appStore }) + #expect(products.contains { $0.id == tertiary.productIdentifier }) + #expect(products.contains { $0.name == "tertiary" }) + #expect(products.count == 2) + #expect(productsById[tertiary.productIdentifier]?.sk1Product == tertiary) } - func test_getProducts_primarySecondaryTertiaryProduct() async { + @Test("getProducts returns the substituted primary, secondary and tertiary products") + func getProducts_primarySecondaryTertiaryProduct() async throws { let primary = MockSkProduct(productIdentifier: "abc") let secondary = MockSkProduct(productIdentifier: "def") let tertiary = MockSkProduct(productIdentifier: "ghi") - + // Mock the products fetcher to return empty set since we're substituting all products let productsResult: Result, Error> = .success([]) let productsFetcher = ProductsFetcherSK1Mock( @@ -128,7 +122,7 @@ class StoreKitManagerTests: XCTestCase { let manager = StoreKitManager( productsManager: productsManager ) - + let substituteProducts = [ "primary": StoreProduct(sk1Product: primary, entitlements: []), "secondary": StoreProduct(sk1Product: secondary, entitlements: []), @@ -140,32 +134,30 @@ class StoreKitManagerTests: XCTestCase { .init(name: "secondary", type: .appStore(.init(id: "123")), id: "123", entitlements: []), .init(name: "tertiary", type: .appStore(.init(id: "uiu")), id: "uiu", entitlements: [.stub()]), ]) - do { - let (productsById, products) = try await manager.getProducts( - forPaywall: paywall, - placement: nil, - substituting: substituteProducts - ) - XCTAssertEqual(productsById[primary.productIdentifier]?.sk1Product, primary) - XCTAssertTrue(products.contains { $0.id == primary.productIdentifier }) - XCTAssertTrue(products.contains { $0.name == "primary" }) - - XCTAssertEqual(productsById[secondary.productIdentifier]?.sk1Product, secondary) - XCTAssertTrue(products.contains { $0.id == secondary.productIdentifier }) - XCTAssertTrue(products.contains { $0.name == "secondary" }) - - XCTAssertEqual(productsById[tertiary.productIdentifier]?.sk1Product, tertiary) - XCTAssertTrue(products.contains { $0.id == tertiary.productIdentifier }) - XCTAssertTrue(products.contains { $0.name == "tertiary" }) - XCTAssertEqual(products.count, 3) - } catch { - XCTFail("couldn't get products") - } + + let (productsById, products) = try await manager.getProducts( + forPaywall: paywall, + placement: nil, + substituting: substituteProducts + ) + #expect(productsById[primary.productIdentifier]?.sk1Product == primary) + #expect(products.contains { $0.id == primary.productIdentifier }) + #expect(products.contains { $0.name == "primary" }) + + #expect(productsById[secondary.productIdentifier]?.sk1Product == secondary) + #expect(products.contains { $0.id == secondary.productIdentifier }) + #expect(products.contains { $0.name == "secondary" }) + + #expect(productsById[tertiary.productIdentifier]?.sk1Product == tertiary) + #expect(products.contains { $0.id == tertiary.productIdentifier }) + #expect(products.contains { $0.name == "tertiary" }) + #expect(products.count == 3) } - func test_getProducts_substitutePrimaryProduct_oneResponseProduct() async { + @Test("getProducts substitutes the primary product when there is one response product") + func getProducts_substitutePrimaryProduct_oneResponseProduct() async throws { let productsResult: Result, Error> = .success([]) - + let productsFetcher = ProductsFetcherSK1Mock( productCompletionResult: productsResult, entitlementsInfo: dependencyContainer.entitlementsInfo @@ -187,23 +179,21 @@ class StoreKitManagerTests: XCTestCase { .setting(\.products, to: [ .init(name: "primary", type: .appStore(.init(id: "1")), id: "1", entitlements: []) ]) - do { - let (productsById, products) = try await manager.getProducts( - forPaywall: paywall, - placement: nil, - substituting: substituteProducts - ) - XCTAssertEqual(productsById.count, 1) - XCTAssertEqual(productsById[primary.productIdentifier]?.sk1Product, primary) - XCTAssertTrue(products.contains { $0.id == primary.productIdentifier }) - XCTAssertTrue(products.contains { $0.name == "primary" }) - XCTAssertEqual(products.count, 1) - } catch { - XCTFail("couldn't get products") - } + + let (productsById, products) = try await manager.getProducts( + forPaywall: paywall, + placement: nil, + substituting: substituteProducts + ) + #expect(productsById.count == 1) + #expect(productsById[primary.productIdentifier]?.sk1Product == primary) + #expect(products.contains { $0.id == primary.productIdentifier }) + #expect(products.contains { $0.name == "primary" }) + #expect(products.count == 1) } - func test_getProducts_substitutePrimaryProduct_twoResponseProducts() async { + @Test("getProducts substitutes the primary product when there are two response products") + func getProducts_substitutePrimaryProduct_twoResponseProducts() async throws { let responseProduct2 = MockSkProduct(productIdentifier: "2") let productsResult: Result, Error> = .success([ StoreProduct(sk1Product: responseProduct2, entitlements: []) @@ -229,20 +219,69 @@ class StoreKitManagerTests: XCTestCase { .init(name: "secondary", type: .appStore(.init(id: "2")), id: "2", entitlements: []) ]) - do { - let (productsById, products) = try await manager.getProducts( - forPaywall: paywall, - placement: nil, - substituting: substituteProducts - ) - XCTAssertEqual(productsById.count, 2) - XCTAssertEqual(productsById[primary.productIdentifier]?.sk1Product, primary) - XCTAssertEqual(products.count, 2) - XCTAssertTrue(products.contains { $0.id == primary.productIdentifier }) - XCTAssertTrue(products.contains { $0.name == "primary" }) - XCTAssertEqual(productsById["2"]?.sk1Product, responseProduct2) - } catch { - XCTFail("couldn't get products") - } + let (productsById, products) = try await manager.getProducts( + forPaywall: paywall, + placement: nil, + substituting: substituteProducts + ) + #expect(productsById.count == 2) + #expect(productsById[primary.productIdentifier]?.sk1Product == primary) + #expect(products.count == 2) + #expect(products.contains { $0.id == primary.productIdentifier }) + #expect(products.contains { $0.name == "primary" }) + #expect(productsById["2"]?.sk1Product == responseProduct2) + } + + @Test("Composite products from earlier paywalls survive when later paywalls load") + func getProducts_compositeMapAccumulatesAcrossPaywalls() async throws { + // A single Apple product (`annual`) merchandised under two different + // billing plans, each on its own paywall, mirrors preloading multiple + // billing-plan paywalls into the shared composite-ID cache. + let annual = MockSkProduct(productIdentifier: "annual") + let productsResult: Result, Error> = .success([ + StoreProduct(sk1Product: annual, entitlements: []) + ]) + let productsFetcher = ProductsFetcherSK1Mock( + productCompletionResult: productsResult, + entitlementsInfo: dependencyContainer.entitlementsInfo + ) + let productsManager = ProductsManager( + entitlementsInfo: dependencyContainer.entitlementsInfo, + storeKitVersion: .storeKit1, + productsFetcher: productsFetcher + ) + let manager = StoreKitManager( + productsManager: productsManager + ) + + let monthlyCommitmentPaywall = Paywall.stub() + .setting(\.products, to: [ + .init( + name: "annual", + type: .appStore(.init(id: "annual", billingPlanType: .monthly)), + id: "annual:MONTHLY", + entitlements: [] + ) + ]) + let upFrontPaywall = Paywall.stub() + .setting(\.products, to: [ + .init( + name: "annual", + type: .appStore(.init(id: "annual", billingPlanType: .upFront)), + id: "annual:UP_FRONT", + entitlements: [] + ) + ]) + + // Load both paywalls in turn, as preloading would. + _ = try await manager.getProducts(forPaywall: monthlyCommitmentPaywall, placement: nil) + _ = try await manager.getProducts(forPaywall: upFrontPaywall, placement: nil) + + // Loading the second paywall must not wipe the first paywall's + // billing-plan product: both composite entries must stay resolvable, since + // a preloaded paywall is never re-resolved when it's later presented. + let composite = await manager.productsByCompositeId + #expect(composite["annual:MONTHLY"] != nil) + #expect(composite["annual:UP_FRONT"] != nil) } }