From 297100cf916534974c9cbf00a7a9041384142d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:55:57 +0200 Subject: [PATCH 1/7] Fix free trial shown for upgrades within an active subscription group A paywall could advertise a free trial that Apple never granted. StoreKit's `isEligibleForIntroOffer` only reflects whether the customer has ever consumed an intro offer in the group, so it returns true for someone who paid for a product in the group but never took a trial. Apple does not apply introductory offers to upgrades, crossgrades, or downgrades, so when such a user bought a different product in a group where they already had an active subscription, the trial was advertised but never applied (they were charged immediately). `ReceiptManager.isFreeTrialAvailable` now also requires that there's no active App Store subscription in the product's subscription group. Once the existing subscription lapses, a fresh purchase is eligible for the trial again. Also removes the never-invalidated StoreKit 2 intro-offer eligibility cache so eligibility is re-queried from StoreKit on each check rather than frozen for the app-process lifetime. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 + .../Receipt Manager/ReceiptManager.swift | 35 ++- .../Receipt Manager/SK2ReceiptManager.swift | 43 ++-- SuperwallKit.xcodeproj/project.pbxproj | 8 + .../ReceiptManagerTrialEligibilityTests.swift | 200 ++++++++++++++++++ .../SK2ReceiptManagerTests.swift | 125 +++++++++++ 6 files changed, 391 insertions(+), 22 deletions(-) create mode 100644 Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift create mode 100644 Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/SK2ReceiptManagerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f9f1daa69..e57075c919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes - Fixes a crash due to concurrent calls to `preloadAllPaywalls`. +- Fixes a free trial being shown for a product when the user already has an active subscription in the same subscription group. Apple does not apply introductory offers to upgrades, crossgrades, or downgrades, so the trial was advertised but never granted on purchase. Trial availability now also requires that there's no active App Store subscription in the product's subscription group. +- Fixes StoreKit 2 introductory offer eligibility being cached for the lifetime of the app process. Eligibility is now re-queried from StoreKit on each check, so it stays correct after the user redeems a trial, after `reset()`, and after user identity switches. ## 4.15.3 diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index ef82f876e6..c4d91c9c90 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -236,13 +236,38 @@ actor ReceiptManager { await manager.loadIntroOfferEligibility(forProducts: storeProducts) } - /// Determines whether a free trial is available based on the product the user is purchasing. + /// Determines whether a free trial will actually be granted when the user purchases `storeProduct`. /// - /// A free trial is available if the user hasn't already purchased within the subscription group of the - /// supplied product. If it isn't a subscription-based product or there are other issues retrieving the products, - /// the outcome will default to whether or not the user has already purchased that product. + /// This is stricter than raw StoreKit intro-offer eligibility. `isEligibleForIntroOffer` + /// only reflects whether the customer has ever *consumed* an intro offer in the product's + /// subscription group — it returns `true` for someone who has paid for a product in the + /// group but never taken a trial. Apple additionally does **not** apply introductory + /// offers to upgrades, crossgrades, or downgrades: if the customer already has an active + /// subscription in the same subscription group, purchasing a product in that group is a + /// product change and no trial is granted, even though `isEligibleForIntroOffer` is `true`. + /// + /// So we also require that there's no active App Store subscription in the product's group, + /// to avoid advertising a free trial that Apple won't honor. (Once the existing subscription + /// lapses, a fresh purchase is a new subscription and the trial applies again.) func isFreeTrialAvailable(for storeProduct: StoreProduct) async -> Bool { - await manager.isEligibleForIntroOffer(storeProduct) + let isEligibleForIntroOffer = await manager.isEligibleForIntroOffer(storeProduct) + if !isEligibleForIntroOffer { + return false + } + + // Non-subscription products have no subscription group, so the + // upgrade/crossgrade/downgrade rule doesn't apply. + guard let subscriptionGroupId = storeProduct.subscriptionGroupIdentifier else { + return true + } + + let deviceSubscriptions = storage.get(LatestDeviceCustomerInfo.self)?.subscriptions ?? [] + let hasActiveSubscriptionInGroup = deviceSubscriptions.contains { subscription in + subscription.store == .appStore + && subscription.isActive + && subscription.subscriptionGroupId == subscriptionGroupId + } + return !hasActiveSubscriptionInGroup } /// Determines whether the user is subscribed to the given product id. diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift index 9d658b369c..ebac7ac9b7 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift @@ -28,7 +28,9 @@ struct PurchaseSnapshot { @available(iOS 15.0, *) actor SK2ReceiptManager: ReceiptManagerType { - private var sk2IntroOfferEligibility: [String: Bool] + /// Resolves intro-offer eligibility live from StoreKit. Injectable so tests can + /// verify eligibility is re-evaluated on every call rather than cached. + private let resolveIntroOfferEligibility: @Sendable (StoreProduct) async -> Bool var purchases: Set var transactionReceipts: [TransactionReceipt] var latestSubscriptionPeriodType: LatestSubscription.PeriodType? @@ -36,26 +38,29 @@ actor SK2ReceiptManager: ReceiptManagerType { var latestSubscriptionState: LatestSubscription.State? init( - sk2IntroOfferEligibility: [String: Bool] = [:], purchases: Set = [], transactionReceipts: [TransactionReceipt] = [], latestSubscriptionPeriodType: LatestSubscription.PeriodType? = nil, latestSubscriptionWillAutoRenew: Bool? = nil, - latestSubscriptionState: LatestSubscription.State? = nil + latestSubscriptionState: LatestSubscription.State? = nil, + resolveIntroOfferEligibility: @escaping @Sendable (StoreProduct) async -> Bool + = { await SK2ReceiptManager.liveIntroOfferEligibility(for: $0) } ) { - self.sk2IntroOfferEligibility = sk2IntroOfferEligibility self.purchases = purchases self.transactionReceipts = transactionReceipts self.latestSubscriptionPeriodType = latestSubscriptionPeriodType self.latestSubscriptionWillAutoRenew = latestSubscriptionWillAutoRenew self.latestSubscriptionState = latestSubscriptionState + self.resolveIntroOfferEligibility = resolveIntroOfferEligibility } - func loadIntroOfferEligibility(forProducts storeProducts: Set) async { - for storeProduct in storeProducts { - sk2IntroOfferEligibility[storeProduct.productIdentifier] = await isEligibleForIntroOffer(storeProduct) - } - } + /// No-op for StoreKit 2. + /// + /// Eligibility is resolved live in `isEligibleForIntroOffer(_:)` on every call + /// rather than pre-warmed into a cache. The previous cache froze eligibility for + /// the lifetime of the process — it survived `reset()` and user identity switches — + /// which could surface a free trial that Apple would not actually grant. + func loadIntroOfferEligibility(forProducts _: Set) async {} func loadPurchases(serverEntitlementsByProductId: [String: Set]) async -> PurchaseSnapshot { var purchases: Set = [] @@ -243,13 +248,21 @@ actor SK2ReceiptManager: ReceiptManagerType { return latestExpiration } + /// Determines whether `storeProduct` is eligible for an introductory offer. + /// + /// Always resolved live (never cached): eligibility can change after a purchase + /// or a user identity switch via `reset()`, and a stale value would surface a + /// free trial that Apple won't grant. func isEligibleForIntroOffer(_ storeProduct: StoreProduct) async -> Bool { + return await resolveIntroOfferEligibility(storeProduct) + } + + /// Live intro-offer eligibility query backed by StoreKit 2. This is the default + /// resolver injected into `init`; tests can substitute their own. + static func liveIntroOfferEligibility(for storeProduct: StoreProduct) async -> Bool { guard let product = storeProduct.product as? SK2StoreProduct else { return false } - if let eligibility = sk2IntroOfferEligibility[storeProduct.productIdentifier] { - return eligibility - } guard product.hasFreeTrial else { return false } @@ -258,10 +271,6 @@ actor SK2ReceiptManager: ReceiptManagerType { // Technically this is covered in hasFreeTrial, but good for unwrapping subscription return false } - if await renewableSubscription.isEligibleForIntroOffer { - // The product is eligible for an introductory offer. - return true - } - return false + return await renewableSubscription.isEligibleForIntroOffer } } diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index f8ba6205c3..14c6de0d15 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -153,12 +153,14 @@ 41EAF9340665BF7459B2C29C /* CoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50458143450675EF205CE2C3 /* CoreDataStack.swift */; }; 424F0357DA9A8BFBC6621047 /* LogScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FCE6A59348C9018F40D7AC5 /* LogScope.swift */; }; 42B707D898A49DCB2B33837C /* CustomCallbackRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B3BC4249A9E9BA8E99EC7C /* CustomCallbackRegistry.swift */; }; + 42FDAFD5AB3EC83713941E32 /* SK2ReceiptManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B1E659458AE78C3908562B /* SK2ReceiptManagerTests.swift */; }; 432FB2AE172725B4ED208416 /* DebugManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5383FA48A6E9EF8F30683C9B /* DebugManager.swift */; }; 43EF1A9F46DECE0EECFD0368 /* HiddenListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9B0C261DCC1ED74DD2BF3EA /* HiddenListener.swift */; }; 44829144E9EFA0CE4A75BBA1 /* ExpressionLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154A9E99D00B9BD8837B798 /* ExpressionLogic.swift */; }; 44E2AE9B0AED16C48027CD21 /* CustomCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = E439B70BB6190AFF6DDB81F2 /* CustomCallback.swift */; }; 454421E34ED200400A001AE1 /* PaywallManagerLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8012E350CCE22B0D892E0F96 /* PaywallManagerLogic.swift */; }; 480C37A4D7A8AB5EE0760BF1 /* PaywallLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC6C4D551369C55D8AFB7F96 /* PaywallLogic.swift */; }; + 498C546594CF7A5DA78575AA /* ReceiptManagerTrialEligibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08CC3D275A02927073952EB /* ReceiptManagerTrialEligibilityTests.swift */; }; 49A7156A67C8BAB23F97EC39 /* EmailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B6BF63B250AE0D83DECFCD0 /* EmailTests.swift */; }; 4A4E5413A8753AFB624D325D /* PermissionTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD2580D6C95C96CC3051BCB /* PermissionTypeTests.swift */; }; 4A4E788046CD308F465B37BF /* ProductsFetcherSK2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57AD390BC73341A49301B4AA /* ProductsFetcherSK2.swift */; }; @@ -883,6 +885,7 @@ 8708F74D80CB9A440F34A556 /* FakeContactsAuthorizationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeContactsAuthorizationStatus.swift; sourceTree = ""; }; 8719AC2E83128EE469E58C36 /* RedeemRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemRequest.swift; sourceTree = ""; }; 87AD727C5A8639E704F7BE98 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = ""; }; + 87B1E659458AE78C3908562B /* SK2ReceiptManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2ReceiptManagerTests.swift; sourceTree = ""; }; 884DF3D8A1CA382BFEE9F8F4 /* ArchiveManifestUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveManifestUsage.swift; sourceTree = ""; }; 887834329A06971D86D5282F /* NotificationProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationProtocols.swift; sourceTree = ""; }; 88EBF6FC3090E004EE1377B4 /* PaywallViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerDelegate.swift; sourceTree = ""; }; @@ -944,6 +947,7 @@ 9F72210F85864F3000F13646 /* ASN1Coder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASN1Coder.swift; sourceTree = ""; }; A00C04BE1449BE21E13B61A0 /* LoggerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerMock.swift; sourceTree = ""; }; A07DC8EA2AB02423AEE5DF26 /* EvaluationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationContext.swift; sourceTree = ""; }; + A08CC3D275A02927073952EB /* ReceiptManagerTrialEligibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptManagerTrialEligibilityTests.swift; sourceTree = ""; }; A0B4279B992779CAD5A0694A /* AdServicesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdServicesResponse.swift; sourceTree = ""; }; A116633D7246EB7BB7AF7229 /* ExpressionEvaluatorMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpressionEvaluatorMock.swift; sourceTree = ""; }; A154A9E99D00B9BD8837B798 /* ExpressionLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpressionLogic.swift; sourceTree = ""; }; @@ -1268,6 +1272,8 @@ 9E3DAD767490972EA30257F9 /* EntitlementProcessorTests.swift */, 39B81D88316F06C0C2757F10 /* MockReceiptData.swift */, 03471273DF4C875227102BE2 /* ReceiptManagerTests.swift */, + A08CC3D275A02927073952EB /* ReceiptManagerTrialEligibilityTests.swift */, + 87B1E659458AE78C3908562B /* SK2ReceiptManagerTests.swift */, ); path = "Receipt Manager"; sourceTree = ""; @@ -3304,8 +3310,10 @@ 847E0BD4BDA515E47608F6A1 /* ProductsFetcherSK2Tests.swift in Sources */, 5EF4EE04BAA930A2CC4379A1 /* RawWebMessageHandlerTests.swift in Sources */, 3BA17B2DA6B69A7B90D39AF9 /* ReceiptManagerTests.swift in Sources */, + 498C546594CF7A5DA78575AA /* ReceiptManagerTrialEligibilityTests.swift in Sources */, 225D6F5363B1520744EABD86 /* RedeemResponseTests.swift in Sources */, ECA7E9C9898CAB24B56E7054 /* SK2PriceFormatRoundingTests.swift in Sources */, + 42FDAFD5AB3EC83713941E32 /* SK2ReceiptManagerTests.swift in Sources */, B8483A96A7AE8440AF1E0C3B /* SKProductSubscriptionPeriodMock.swift in Sources */, E1A838C9CE62C9479D0C68F4 /* SWDebugManagerLogicTests.swift in Sources */, C77A626D379969A86B900488 /* SWWebViewLoadingHandlerTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift new file mode 100644 index 0000000000..7789ebdc35 --- /dev/null +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift @@ -0,0 +1,200 @@ +// +// ReceiptManagerTrialEligibilityTests.swift +// SuperwallKitTests +// +// Regression tests for a bug where a paywall showed a free trial that Apple never +// granted. A customer who had paid for a product in a subscription group since 2017 +// (but never taken a trial) is still reported eligible by StoreKit's +// `isEligibleForIntroOffer`. When they bought a *different* product in that group +// while their existing subscription was active, Apple treated it as an upgrade and +// applied no introductory offer — yet the paywall advertised one. +// +// `ReceiptManager.isFreeTrialAvailable` now additionally requires that there's no +// active App Store subscription in the product's subscription group, since Apple +// doesn't apply intro offers to upgrades/crossgrades/downgrades. +// + +import Foundation +import StoreKit +import Testing +@testable import SuperwallKit + +struct ReceiptManagerTrialEligibilityTests { + // Held for the lifetime of each test: `ReceiptManager` keeps an `unowned` reference + // to its factory, so the container must outlive the manager. + let dependencyContainer = DependencyContainer() + + private func makeReceiptManager( + isEligibleForIntroOffer: Bool, + deviceSubscriptions: [SubscriptionTransaction] + ) -> (manager: ReceiptManager, productsManager: ProductsManager) { + let storage: Storage = dependencyContainer.storage + storage.save( + CustomerInfo( + subscriptions: deviceSubscriptions, + nonSubscriptions: [], + entitlements: [] + ), + forType: LatestDeviceCustomerInfo.self + ) + + let productsFetcher = ProductsFetcherSK1Mock( + productCompletionResult: .success([]), + entitlementsInfo: dependencyContainer.entitlementsInfo + ) + let productsManager = ProductsManager( + entitlementsInfo: dependencyContainer.entitlementsInfo, + storeKitVersion: .storeKit1, + productsFetcher: productsFetcher + ) + + let receiptManager = ReceiptManager( + storeKitVersion: .storeKit2, + shouldBypassAppTransactionCheck: true, + productsManager: productsManager, + receiptManager: MockReceiptManagerType(isEligibleForIntroOffer: isEligibleForIntroOffer), + receiptDelegate: nil, + factory: dependencyContainer, + storage: storage + ) + return (receiptManager, productsManager) + } + + private func makeProduct( + id: String = "com.app.gold", + subscriptionGroup: String? = "group_A" + ) -> StoreProduct { + return StoreProduct( + sk1Product: MockSkProduct( + productIdentifier: id, + subscriptionGroupIdentifier: subscriptionGroup + ) + ) + } + + private func activeSubscription( + productId: String, + group: String?, + isActive: Bool = true, + store: ProductStore = .appStore + ) -> SubscriptionTransaction { + return SubscriptionTransaction( + transactionId: "txn_\(productId)", + productId: productId, + purchaseDate: Date(timeIntervalSince1970: 0), + willRenew: isActive, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: isActive, + expirationDate: nil, + offerType: nil, + subscriptionGroupId: group, + store: store + ) + } + + @Test("No trial when an active subscription exists in the same group (upgrade/crossgrade)") + func noTrialWhenActiveSubscriptionInSameGroup() async { + // The reported incident: active Silver in group_A, buying Gold in group_A. + let (manager, productsManager) = makeReceiptManager( + isEligibleForIntroOffer: true, + deviceSubscriptions: [activeSubscription(productId: "com.app.silver", group: "group_A")] + ) + _ = productsManager + let gold = makeProduct(id: "com.app.gold", subscriptionGroup: "group_A") + #expect(await manager.isFreeTrialAvailable(for: gold) == false) + } + + @Test("Trial available once the same-group subscription has lapsed") + func trialWhenSameGroupSubscriptionInactive() async { + // After Silver lapsed, a fresh purchase in the group is a new subscription, so + // Apple applies the trial again. + let (manager, productsManager) = makeReceiptManager( + isEligibleForIntroOffer: true, + deviceSubscriptions: [ + activeSubscription(productId: "com.app.silver", group: "group_A", isActive: false) + ] + ) + _ = productsManager + let silver = makeProduct(id: "com.app.silver", subscriptionGroup: "group_A") + #expect(await manager.isFreeTrialAvailable(for: silver) == true) + } + + @Test("Trial available when the active subscription is in a different group") + func trialWhenActiveSubscriptionInDifferentGroup() async { + let (manager, productsManager) = makeReceiptManager( + isEligibleForIntroOffer: true, + deviceSubscriptions: [activeSubscription(productId: "com.app.other", group: "group_B")] + ) + _ = productsManager + let gold = makeProduct(id: "com.app.gold", subscriptionGroup: "group_A") + #expect(await manager.isFreeTrialAvailable(for: gold) == true) + } + + @Test("Trial available when there are no subscriptions at all") + func trialWhenNoSubscriptions() async { + let (manager, productsManager) = makeReceiptManager( + isEligibleForIntroOffer: true, + deviceSubscriptions: [] + ) + _ = productsManager + let gold = makeProduct(id: "com.app.gold", subscriptionGroup: "group_A") + #expect(await manager.isFreeTrialAvailable(for: gold) == true) + } + + @Test("No trial when StoreKit reports the customer is intro-ineligible") + func noTrialWhenIneligible() async { + // Even with no blocking subscription, an ineligible customer gets no trial. + let (manager, productsManager) = makeReceiptManager( + isEligibleForIntroOffer: false, + deviceSubscriptions: [] + ) + _ = productsManager + let gold = makeProduct(id: "com.app.gold", subscriptionGroup: "group_A") + #expect(await manager.isFreeTrialAvailable(for: gold) == false) + } + + @Test("A non-App Store subscription in the group does not block the trial") + func nonAppStoreSubscriptionDoesNotBlock() async { + // The upgrade rule is an App Store concept; a web/Stripe entitlement in the same + // group must not suppress an App Store trial. + let (manager, productsManager) = makeReceiptManager( + isEligibleForIntroOffer: true, + deviceSubscriptions: [ + activeSubscription(productId: "com.app.silver", group: "group_A", store: .stripe) + ] + ) + _ = productsManager + let gold = makeProduct(id: "com.app.gold", subscriptionGroup: "group_A") + #expect(await manager.isFreeTrialAvailable(for: gold) == true) + } +} + +/// Minimal `ReceiptManagerType` whose `isEligibleForIntroOffer` is fully controlled, +/// so tests can isolate `ReceiptManager`'s upgrade/crossgrade gating logic. +private final class MockReceiptManagerType: ReceiptManagerType { + let isEligibleForIntroOfferResult: Bool + var purchases: Set = [] + var transactionReceipts: [TransactionReceipt] = [] + var latestSubscriptionPeriodType: LatestSubscription.PeriodType? + var latestSubscriptionWillAutoRenew: Bool? + var latestSubscriptionState: LatestSubscription.State? + + init(isEligibleForIntroOffer: Bool) { + self.isEligibleForIntroOfferResult = isEligibleForIntroOffer + } + + func loadIntroOfferEligibility(forProducts _: Set) async {} + + func loadPurchases(serverEntitlementsByProductId _: [String: Set]) async -> PurchaseSnapshot { + return PurchaseSnapshot( + purchases: [], + customerInfo: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []) + ) + } + + func isEligibleForIntroOffer(_ storeProduct: StoreProduct) async -> Bool { + return isEligibleForIntroOfferResult + } +} diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/SK2ReceiptManagerTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/SK2ReceiptManagerTests.swift new file mode 100644 index 0000000000..bab3ff2f4e --- /dev/null +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/SK2ReceiptManagerTests.swift @@ -0,0 +1,125 @@ +// +// SK2ReceiptManagerTests.swift +// SuperwallKitTests +// +// Regression tests for a bug where SK2 introductory-offer eligibility was cached +// in `SK2ReceiptManager` for the lifetime of the app process. The cache was never +// invalidated — it survived `reset()`, user identity switches, and purchases — so a +// paywall could show a free trial that Apple would not actually grant. The fix +// removes the cache and resolves eligibility live from StoreKit on every call. +// + +import Foundation +import Testing +@testable import SuperwallKit + +struct SK2ReceiptManagerTests { + /// A lightweight StoreProduct to feed the manager. The injected resolvers in + /// these tests ignore the product, so an SK1-backed mock product is sufficient. + private func makeStoreProduct() -> StoreProduct { + return StoreProduct( + sk1Product: MockSkProduct( + productIdentifier: "com.superwall.product", + subscriptionGroupIdentifier: "group" + ), + entitlements: [] + ) + } + + @Test("isEligibleForIntroOffer re-queries StoreKit on every call and is never cached") + func eligibilityIsNotCached() async { + guard #available(iOS 15.0, *) else { + return + } + // Returns `true` on the first call and `false` afterwards, simulating Apple's + // eligibility flipping once the user has consumed their intro offer. A cache + // would freeze the first `true` and return it forever. + let calls = CallCounter() + let manager = SK2ReceiptManager( + resolveIntroOfferEligibility: { _ in + await calls.increment() == 1 + } + ) + let product = makeStoreProduct() + + let first = await manager.isEligibleForIntroOffer(product) + let second = await manager.isEligibleForIntroOffer(product) + + #expect(first == true) + #expect(second == false) + // StoreKit must be consulted on each call rather than served from a cache. + #expect(await calls.count == 2) + } + + @Test("loadIntroOfferEligibility is a no-op and does not freeze a value for later reads") + func loadDoesNotFreezeEligibility() async { + guard #available(iOS 15.0, *) else { + return + } + // Eligibility starts `true`, then becomes `false` (e.g. after the user starts a + // trial). The old implementation called `isEligibleForIntroOffer` inside + // `loadIntroOfferEligibility` and cached the result, so the later read returned a + // stale `true`. The fixed implementation must reflect the current value. + let eligibility = MutableFlag(value: true) + let manager = SK2ReceiptManager( + resolveIntroOfferEligibility: { _ in + await eligibility.read() + } + ) + let product = makeStoreProduct() + + // `loadIntroOfferEligibility` is now a no-op — it must not consult the resolver + // (the old code queried eligibility here and cached the result). + await manager.loadIntroOfferEligibility(forProducts: [product]) + #expect(await eligibility.reads == 0) + + #expect(await manager.isEligibleForIntroOffer(product) == true) + + await eligibility.set(false) + #expect(await manager.isEligibleForIntroOffer(product) == false) + + // One read per `isEligibleForIntroOffer` call — never served from a frozen value. + #expect(await eligibility.reads == 2) + } + + @Test("the default resolver runs the live StoreKit path end-to-end") + func defaultResolverIsWired() async { + guard #available(iOS 15.0, *) else { + return + } + // No custom resolver, so the production default `liveIntroOfferEligibility(for:)` + // runs. A StoreKit 1-backed product isn't an `SK2StoreProduct`, so the live path + // short-circuits to `false` without touching StoreKit. This proves the default + // resolver is wired through `init` and the no-cache path runs end-to-end. + let manager = SK2ReceiptManager() + let sk1BackedProduct = makeStoreProduct() + #expect(await manager.isEligibleForIntroOffer(sk1BackedProduct) == false) + } +} + +private actor CallCounter { + private(set) var count = 0 + + func increment() -> Int { + count += 1 + return count + } +} + +private actor MutableFlag { + private(set) var value: Bool + private(set) var reads = 0 + + init(value: Bool) { + self.value = value + } + + func read() -> Bool { + reads += 1 + return value + } + + func set(_ newValue: Bool) { + value = newValue + } +} From 17b2490b4f39c18ba631679f9a1b9de3e7bb8830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:17:21 +0200 Subject: [PATCH 2/7] Address review: derive active subscription groups in loadPurchasedProducts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot-based gate read `LatestDeviceCustomerInfo.subscriptions`, which fails open: that array is always empty on the StoreKit 1 path and before the first device snapshot is saved, so an active same-group subscriber could still see a trial on an upgrade that Apple charges immediately. `loadPurchasedProducts` already fetches the purchased products (which carry the subscription group ID on both StoreKit 1 and 2) and has per-product active state, so it now records the set of subscription groups the user has an active subscription in. That method runs to completion before any paywall opens (config is only marked retrieved after it finishes, and presentation waits for config), so `isFreeTrialAvailable` always reflects current subscription state for both StoreKit versions — no empty-snapshot fail-open. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Receipt Manager/ReceiptManager.swift | 47 +++++++--- .../ReceiptManagerTrialEligibilityTests.swift | 87 ++++++------------- 2 files changed, 62 insertions(+), 72 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index c4d91c9c90..99f38bf44f 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -30,6 +30,11 @@ actor ReceiptManager { private let delegateWrapper: ReceiptRefreshDelegateWrapper private unowned let factory: Factory private unowned let storage: Storage + /// Subscription group IDs the user currently has an active subscription in. Computed + /// during `loadPurchasedProducts` from the active purchases and their fetched products, + /// so it works for both StoreKit 1 and StoreKit 2. Used to suppress free trials on + /// upgrades/crossgrades/downgrades, which Apple won't apply an intro offer to. + private var activeSubscriptionGroupIds: Set static var appTransactionId: String? static var appId: UInt64? /// Set from `AppTransaction.shared` when available (iOS 16+). @@ -43,13 +48,15 @@ actor ReceiptManager { receiptManager: ReceiptManagerType? = nil, // For testing receiptDelegate: ReceiptDelegate?, factory: Factory, - storage: Storage + storage: Storage, + activeSubscriptionGroupIds: Set = [] // For testing ) { self.storeKitVersion = storeKitVersion self.shouldBypassAppTransactionCheck = shouldBypassAppTransactionCheck self.productsManager = productsManager self.factory = factory self.storage = storage + self.activeSubscriptionGroupIds = activeSubscriptionGroupIds if let receiptManager = receiptManager { self.manager = receiptManager @@ -233,6 +240,15 @@ actor ReceiptManager { return } + // Record which subscription groups the user has an *active* subscription in, so + // `isFreeTrialAvailable` can suppress trials on upgrades/crossgrades. Computed from + // the fetched products (which carry the group ID on both StoreKit 1 and 2) rather + // than the device snapshot, whose `subscriptions` array is empty on StoreKit 1. + activeSubscriptionGroupIds = computeActiveSubscriptionGroupIds( + forActivePurchasesIn: onDeviceSnapshot.purchases, + amongProducts: storeProducts + ) + await manager.loadIntroOfferEligibility(forProducts: storeProducts) } @@ -246,8 +262,8 @@ actor ReceiptManager { /// subscription in the same subscription group, purchasing a product in that group is a /// product change and no trial is granted, even though `isEligibleForIntroOffer` is `true`. /// - /// So we also require that there's no active App Store subscription in the product's group, - /// to avoid advertising a free trial that Apple won't honor. (Once the existing subscription + /// So we also require that there's no active subscription in the product's group, to + /// avoid advertising a free trial that Apple won't honor. (Once the existing subscription /// lapses, a fresh purchase is a new subscription and the trial applies again.) func isFreeTrialAvailable(for storeProduct: StoreProduct) async -> Bool { let isEligibleForIntroOffer = await manager.isEligibleForIntroOffer(storeProduct) @@ -261,13 +277,10 @@ actor ReceiptManager { return true } - let deviceSubscriptions = storage.get(LatestDeviceCustomerInfo.self)?.subscriptions ?? [] - let hasActiveSubscriptionInGroup = deviceSubscriptions.contains { subscription in - subscription.store == .appStore - && subscription.isActive - && subscription.subscriptionGroupId == subscriptionGroupId - } - return !hasActiveSubscriptionInGroup + // `activeSubscriptionGroupIds` is populated in `loadPurchasedProducts`, which always + // completes before a paywall opens (config is only marked retrieved after it runs, + // and presentation waits for config), so this reflects current subscription state. + return !activeSubscriptionGroupIds.contains(subscriptionGroupId) } /// Determines whether the user is subscribed to the given product id. @@ -348,6 +361,20 @@ actor ReceiptManager { } } +/// Subscription group IDs that have at least one active purchase among `storeProducts`. +/// File-scoped (it needs no actor state) so it doesn't count toward the actor body length. +private func computeActiveSubscriptionGroupIds( + forActivePurchasesIn purchases: Set, + amongProducts storeProducts: Set +) -> Set { + let activeProductIds = Set(purchases.filter { $0.isActive }.map { $0.id }) + return Set( + storeProducts + .filter { activeProductIds.contains($0.productIdentifier) } + .compactMap { $0.subscriptionGroupIdentifier } + ) +} + final class ReceiptRefreshDelegateWrapper: NSObject, SKRequestDelegate { weak var receiptManager: ReceiptManager? diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift index 7789ebdc35..e2766dfe51 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift @@ -5,17 +5,17 @@ // Regression tests for a bug where a paywall showed a free trial that Apple never // granted. A customer who had paid for a product in a subscription group since 2017 // (but never taken a trial) is still reported eligible by StoreKit's -// `isEligibleForIntroOffer`. When they bought a *different* product in that group -// while their existing subscription was active, Apple treated it as an upgrade and -// applied no introductory offer — yet the paywall advertised one. +// `isEligibleForIntroOffer`. When they bought a *different* product in that group while +// their existing subscription was active, Apple treated it as an upgrade and applied no +// introductory offer — yet the paywall advertised one. // -// `ReceiptManager.isFreeTrialAvailable` now additionally requires that there's no -// active App Store subscription in the product's subscription group, since Apple -// doesn't apply intro offers to upgrades/crossgrades/downgrades. +// `ReceiptManager.isFreeTrialAvailable` now also requires that there's no active +// subscription in the product's subscription group. The set of active groups is computed +// in `loadPurchasedProducts` from the active purchases and their fetched products (so it +// works for both StoreKit 1 and 2); these tests seed it directly to isolate the gate. // import Foundation -import StoreKit import Testing @testable import SuperwallKit @@ -26,18 +26,8 @@ struct ReceiptManagerTrialEligibilityTests { private func makeReceiptManager( isEligibleForIntroOffer: Bool, - deviceSubscriptions: [SubscriptionTransaction] + activeSubscriptionGroupIds: Set ) -> (manager: ReceiptManager, productsManager: ProductsManager) { - let storage: Storage = dependencyContainer.storage - storage.save( - CustomerInfo( - subscriptions: deviceSubscriptions, - nonSubscriptions: [], - entitlements: [] - ), - forType: LatestDeviceCustomerInfo.self - ) - let productsFetcher = ProductsFetcherSK1Mock( productCompletionResult: .success([]), entitlementsInfo: dependencyContainer.entitlementsInfo @@ -55,7 +45,8 @@ struct ReceiptManagerTrialEligibilityTests { receiptManager: MockReceiptManagerType(isEligibleForIntroOffer: isEligibleForIntroOffer), receiptDelegate: nil, factory: dependencyContainer, - storage: storage + storage: dependencyContainer.storage, + activeSubscriptionGroupIds: activeSubscriptionGroupIds ) return (receiptManager, productsManager) } @@ -72,34 +63,12 @@ struct ReceiptManagerTrialEligibilityTests { ) } - private func activeSubscription( - productId: String, - group: String?, - isActive: Bool = true, - store: ProductStore = .appStore - ) -> SubscriptionTransaction { - return SubscriptionTransaction( - transactionId: "txn_\(productId)", - productId: productId, - purchaseDate: Date(timeIntervalSince1970: 0), - willRenew: isActive, - isRevoked: false, - isInGracePeriod: false, - isInBillingRetryPeriod: false, - isActive: isActive, - expirationDate: nil, - offerType: nil, - subscriptionGroupId: group, - store: store - ) - } - @Test("No trial when an active subscription exists in the same group (upgrade/crossgrade)") func noTrialWhenActiveSubscriptionInSameGroup() async { // The reported incident: active Silver in group_A, buying Gold in group_A. let (manager, productsManager) = makeReceiptManager( isEligibleForIntroOffer: true, - deviceSubscriptions: [activeSubscription(productId: "com.app.silver", group: "group_A")] + activeSubscriptionGroupIds: ["group_A"] ) _ = productsManager let gold = makeProduct(id: "com.app.gold", subscriptionGroup: "group_A") @@ -108,13 +77,11 @@ struct ReceiptManagerTrialEligibilityTests { @Test("Trial available once the same-group subscription has lapsed") func trialWhenSameGroupSubscriptionInactive() async { - // After Silver lapsed, a fresh purchase in the group is a new subscription, so - // Apple applies the trial again. + // After Silver lapsed it's no longer in the active set, so a fresh purchase in the + // group is a new subscription and Apple applies the trial again. let (manager, productsManager) = makeReceiptManager( isEligibleForIntroOffer: true, - deviceSubscriptions: [ - activeSubscription(productId: "com.app.silver", group: "group_A", isActive: false) - ] + activeSubscriptionGroupIds: [] ) _ = productsManager let silver = makeProduct(id: "com.app.silver", subscriptionGroup: "group_A") @@ -125,18 +92,18 @@ struct ReceiptManagerTrialEligibilityTests { func trialWhenActiveSubscriptionInDifferentGroup() async { let (manager, productsManager) = makeReceiptManager( isEligibleForIntroOffer: true, - deviceSubscriptions: [activeSubscription(productId: "com.app.other", group: "group_B")] + activeSubscriptionGroupIds: ["group_B"] ) _ = productsManager let gold = makeProduct(id: "com.app.gold", subscriptionGroup: "group_A") #expect(await manager.isFreeTrialAvailable(for: gold) == true) } - @Test("Trial available when there are no subscriptions at all") - func trialWhenNoSubscriptions() async { + @Test("Trial available when there are no active subscriptions") + func trialWhenNoActiveSubscriptions() async { let (manager, productsManager) = makeReceiptManager( isEligibleForIntroOffer: true, - deviceSubscriptions: [] + activeSubscriptionGroupIds: [] ) _ = productsManager let gold = makeProduct(id: "com.app.gold", subscriptionGroup: "group_A") @@ -145,29 +112,25 @@ struct ReceiptManagerTrialEligibilityTests { @Test("No trial when StoreKit reports the customer is intro-ineligible") func noTrialWhenIneligible() async { - // Even with no blocking subscription, an ineligible customer gets no trial. + // Ineligible short-circuits before the active-subscription check. let (manager, productsManager) = makeReceiptManager( isEligibleForIntroOffer: false, - deviceSubscriptions: [] + activeSubscriptionGroupIds: ["group_A"] ) _ = productsManager let gold = makeProduct(id: "com.app.gold", subscriptionGroup: "group_A") #expect(await manager.isFreeTrialAvailable(for: gold) == false) } - @Test("A non-App Store subscription in the group does not block the trial") - func nonAppStoreSubscriptionDoesNotBlock() async { - // The upgrade rule is an App Store concept; a web/Stripe entitlement in the same - // group must not suppress an App Store trial. + @Test("A product with no subscription group is unaffected by the active-group check") + func trialWhenProductHasNoSubscriptionGroup() async { let (manager, productsManager) = makeReceiptManager( isEligibleForIntroOffer: true, - deviceSubscriptions: [ - activeSubscription(productId: "com.app.silver", group: "group_A", store: .stripe) - ] + activeSubscriptionGroupIds: ["group_A"] ) _ = productsManager - let gold = makeProduct(id: "com.app.gold", subscriptionGroup: "group_A") - #expect(await manager.isFreeTrialAvailable(for: gold) == true) + let product = makeProduct(id: "com.app.lifetime", subscriptionGroup: nil) + #expect(await manager.isFreeTrialAvailable(for: product) == true) } } From 152fa05a209ba4ca933e477336fafc5e12c79c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:31:20 +0200 Subject: [PATCH 3/7] Address review: don't drop active groups when a product can't be fetched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Product.products(for:)` silently omits IDs it can't resolve (e.g. a subscription the user still holds whose product was removed from App Store Connect), so deriving active subscription groups solely from the fetched products could drop a still-active group and let a trial show on an upgrade. `computeActiveSubscriptionGroupIds` now unions two sources: the active subscription transactions in the snapshot (StoreKit 2 transactions carry the group ID, so this survives a delisted product) and the active purchases' fetched-product groups (StoreKit 1's only source, where the snapshot has no subscriptions). The residual fetch dependency only affects StoreKit 1 — pre-existing, and backstopped by SK1's own `isEligibleForIntroOffer`, which already blocks any same-group purchase. Adds unit tests for the computation covering the delisted-product, StoreKit 1, and inactive-subscription cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Receipt Manager/ReceiptManager.swift | 36 +++++----- .../ReceiptManagerTrialEligibilityTests.swift | 71 +++++++++++++++++++ 2 files changed, 91 insertions(+), 16 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index 99f38bf44f..9af17ecb84 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -241,12 +241,10 @@ actor ReceiptManager { } // Record which subscription groups the user has an *active* subscription in, so - // `isFreeTrialAvailable` can suppress trials on upgrades/crossgrades. Computed from - // the fetched products (which carry the group ID on both StoreKit 1 and 2) rather - // than the device snapshot, whose `subscriptions` array is empty on StoreKit 1. + // `isFreeTrialAvailable` can suppress trials on upgrades/crossgrades. activeSubscriptionGroupIds = computeActiveSubscriptionGroupIds( - forActivePurchasesIn: onDeviceSnapshot.purchases, - amongProducts: storeProducts + from: onDeviceSnapshot, + storeProducts: storeProducts ) await manager.loadIntroOfferEligibility(forProducts: storeProducts) @@ -361,18 +359,24 @@ actor ReceiptManager { } } -/// Subscription group IDs that have at least one active purchase among `storeProducts`. -/// File-scoped (it needs no actor state) so it doesn't count toward the actor body length. -private func computeActiveSubscriptionGroupIds( - forActivePurchasesIn purchases: Set, - amongProducts storeProducts: Set +/// Subscription group IDs the user currently has an active subscription in, unioned from two +/// sources so none is dropped: snapshot transactions (StoreKit 2 carries the group ID, so this +/// survives a delisted product) and active purchases' fetched-product groups (StoreKit 1's only +/// source). File-scoped so it doesn't count toward the actor body length. +func computeActiveSubscriptionGroupIds( + from snapshot: PurchaseSnapshot, + storeProducts: Set ) -> Set { - let activeProductIds = Set(purchases.filter { $0.isActive }.map { $0.id }) - return Set( - storeProducts - .filter { activeProductIds.contains($0.productIdentifier) } - .compactMap { $0.subscriptionGroupIdentifier } - ) + let transactionGroupIds = snapshot.customerInfo.subscriptions + .filter { $0.isActive } + .compactMap { $0.subscriptionGroupId } + + let activeProductIds = Set(snapshot.purchases.filter { $0.isActive }.map { $0.id }) + let productGroupIds = storeProducts + .filter { activeProductIds.contains($0.productIdentifier) } + .compactMap { $0.subscriptionGroupIdentifier } + + return Set(transactionGroupIds).union(productGroupIds) } final class ReceiptRefreshDelegateWrapper: NSObject, SKRequestDelegate { diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift index e2766dfe51..9c01fcf65d 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift @@ -132,6 +132,77 @@ struct ReceiptManagerTrialEligibilityTests { let product = makeProduct(id: "com.app.lifetime", subscriptionGroup: nil) #expect(await manager.isFreeTrialAvailable(for: product) == true) } + + // MARK: - Active group computation + + private func subscriptionTransaction( + productId: String, + group: String?, + isActive: Bool + ) -> SubscriptionTransaction { + return SubscriptionTransaction( + transactionId: "txn_\(productId)", + productId: productId, + purchaseDate: Date(timeIntervalSince1970: 0), + willRenew: isActive, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: isActive, + expirationDate: nil, + subscriptionGroupId: group + ) + } + + @Test("Active group is captured from the transaction even when its product can't be fetched") + func activeGroupFromTransactionWhenProductMissing() { + // The product (e.g. removed from App Store Connect) isn't in storeProducts, but the + // StoreKit 2 transaction still carries the group — so the active group isn't dropped. + let snapshot = PurchaseSnapshot( + purchases: [Purchase(id: "com.app.silver", isActive: true, purchaseDate: Date(timeIntervalSince1970: 0))], + customerInfo: CustomerInfo( + subscriptions: [subscriptionTransaction(productId: "com.app.silver", group: "group_A", isActive: true)], + nonSubscriptions: [], + entitlements: [] + ) + ) + let groups = computeActiveSubscriptionGroupIds(from: snapshot, storeProducts: []) + #expect(groups == ["group_A"]) + } + + @Test("Active group is captured from fetched products (StoreKit 1 path)") + func activeGroupFromFetchedProducts() { + // StoreKit 1 snapshots have no subscriptions, so the group comes from the fetched product. + let snapshot = PurchaseSnapshot( + purchases: [Purchase(id: "com.app.silver", isActive: true, purchaseDate: Date(timeIntervalSince1970: 0))], + customerInfo: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []) + ) + let storeProducts: Set = [ + StoreProduct( + sk1Product: MockSkProduct(productIdentifier: "com.app.silver", subscriptionGroupIdentifier: "group_A") + ) + ] + let groups = computeActiveSubscriptionGroupIds(from: snapshot, storeProducts: storeProducts) + #expect(groups == ["group_A"]) + } + + @Test("Inactive subscriptions don't contribute an active group") + func inactiveSubscriptionsExcluded() { + let snapshot = PurchaseSnapshot( + purchases: [Purchase(id: "com.app.silver", isActive: false, purchaseDate: Date(timeIntervalSince1970: 0))], + customerInfo: CustomerInfo( + subscriptions: [subscriptionTransaction(productId: "com.app.silver", group: "group_A", isActive: false)], + nonSubscriptions: [], + entitlements: [] + ) + ) + let storeProducts: Set = [ + StoreProduct( + sk1Product: MockSkProduct(productIdentifier: "com.app.silver", subscriptionGroupIdentifier: "group_A") + ) + ] + #expect(computeActiveSubscriptionGroupIds(from: snapshot, storeProducts: storeProducts).isEmpty) + } } /// Minimal `ReceiptManagerType` whose `isEligibleForIntroOffer` is fully controlled, From 7f8fd3fb6d13fe60c8b4c6aa6db23f69736ad07e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:50:30 +0200 Subject: [PATCH 4/7] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e57075c919..1ce631cb81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes - Fixes a crash due to concurrent calls to `preloadAllPaywalls`. -- Fixes a free trial being shown for a product when the user already has an active subscription in the same subscription group. Apple does not apply introductory offers to upgrades, crossgrades, or downgrades, so the trial was advertised but never granted on purchase. Trial availability now also requires that there's no active App Store subscription in the product's subscription group. +- Fixes an intro offer eligibility mismatch between the paywall and the payment sheet when upgrading/crossgrading/downgrading. - Fixes StoreKit 2 introductory offer eligibility being cached for the lifetime of the app process. Eligibility is now re-queried from StoreKit on each check, so it stays correct after the user redeems a trial, after `reset()`, and after user identity switches. ## 4.15.3 From 5f550d9d5befc6b9965f352816fcbfa1fbb2f4ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:02:21 +0200 Subject: [PATCH 5/7] Update CHANGELOG.md --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ce631cb81..da3cf495fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,6 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes a crash due to concurrent calls to `preloadAllPaywalls`. - Fixes an intro offer eligibility mismatch between the paywall and the payment sheet when upgrading/crossgrading/downgrading. -- Fixes StoreKit 2 introductory offer eligibility being cached for the lifetime of the app process. Eligibility is now re-queried from StoreKit on each check, so it stays correct after the user redeems a trial, after `reset()`, and after user identity switches. ## 4.15.3 From 6b6cff8d324991a25c4b29fefac958b21f7702bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:10:44 +0200 Subject: [PATCH 6/7] Address review: refresh active-group state on every load, not just on fetch success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `activeSubscriptionGroupIds` was only recomputed after the purchased products fetched successfully, so a failed fetch left it describing the previous load — which could hide a trial from a lapsed user or show one to an active same-group subscriber. It's now refreshed from the snapshot's transactions *before* the fetch guard (StoreKit 2 transactions carry the group, so no fetch is needed), and enriched with product-derived groups when the fetch succeeds. So `isFreeTrialAvailable` always reflects the current load. Reset/identity changes don't need to clear it: the set is derived from the device's StoreKit transactions, which are Apple-ID-scoped and unchanged by a Superwall identity switch — and clearing without an immediate rebuild would briefly fail open instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Receipt Manager/ReceiptManager.swift | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index 9af17ecb84..71586dab9c 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -230,6 +230,11 @@ actor ReceiptManager { await receiptDelegate?.syncSubscriptionStatus(purchases: onDeviceSnapshot.purchases) + // Refresh from this load's snapshot first, so the active-group state never describes a + // previous load: the transactions carry the group (StoreKit 2) without the fetch below. + // (Apple-ID-scoped, so an identity change / `reset()` doesn't require clearing it.) + activeSubscriptionGroupIds = computeActiveSubscriptionGroupIds(from: onDeviceSnapshot, storeProducts: []) + let purchasedProductIds = Set(onDeviceSnapshot.purchases.map { $0.id }) guard let storeProducts = try? await productsManager.products( @@ -240,29 +245,18 @@ actor ReceiptManager { return } - // Record which subscription groups the user has an *active* subscription in, so - // `isFreeTrialAvailable` can suppress trials on upgrades/crossgrades. - activeSubscriptionGroupIds = computeActiveSubscriptionGroupIds( - from: onDeviceSnapshot, - storeProducts: storeProducts - ) + // A successful fetch enriches the set with product-derived groups (StoreKit 1's source). + activeSubscriptionGroupIds = computeActiveSubscriptionGroupIds(from: onDeviceSnapshot, storeProducts: storeProducts) await manager.loadIntroOfferEligibility(forProducts: storeProducts) } /// Determines whether a free trial will actually be granted when the user purchases `storeProduct`. /// - /// This is stricter than raw StoreKit intro-offer eligibility. `isEligibleForIntroOffer` - /// only reflects whether the customer has ever *consumed* an intro offer in the product's - /// subscription group — it returns `true` for someone who has paid for a product in the - /// group but never taken a trial. Apple additionally does **not** apply introductory - /// offers to upgrades, crossgrades, or downgrades: if the customer already has an active - /// subscription in the same subscription group, purchasing a product in that group is a - /// product change and no trial is granted, even though `isEligibleForIntroOffer` is `true`. - /// - /// So we also require that there's no active subscription in the product's group, to - /// avoid advertising a free trial that Apple won't honor. (Once the existing subscription - /// lapses, a fresh purchase is a new subscription and the trial applies again.) + /// Stricter than raw `isEligibleForIntroOffer` (which only reflects whether the customer ever + /// *consumed* an intro in the group): Apple doesn't apply intro offers to upgrades, crossgrades, + /// or downgrades, so we also require no active subscription in the product's group. Once the + /// existing subscription lapses, a fresh purchase is eligible again. func isFreeTrialAvailable(for storeProduct: StoreProduct) async -> Bool { let isEligibleForIntroOffer = await manager.isEligibleForIntroOffer(storeProduct) if !isEligibleForIntroOffer { From 495161c82502f20d21ab044c50dc381f80705938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:05:06 +0200 Subject: [PATCH 7/7] Address review: don't publish a half-built active-group set before the fetch await MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ReceiptManager` is an actor, so the `await productsManager.products(...)` in loadPurchasedProducts is a suspension point where a re-entrant `isFreeTrialAvailable` can run. The previous code assigned `activeSubscriptionGroupIds` from the snapshot *before* that await; on StoreKit 1 the snapshot has no subscription groups, so the set was momentarily empty and a concurrent paywall refresh could show a trial for an upgrade. `activeSubscriptionGroupIds` is now assigned only *after* the await — once on success (snapshot ∪ product-derived groups) and once in the failure branch (snapshot only). During the suspension the property keeps its previous value rather than a half-built one. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Receipt Manager/ReceiptManager.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift index 71586dab9c..69b0211061 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift @@ -230,11 +230,6 @@ actor ReceiptManager { await receiptDelegate?.syncSubscriptionStatus(purchases: onDeviceSnapshot.purchases) - // Refresh from this load's snapshot first, so the active-group state never describes a - // previous load: the transactions carry the group (StoreKit 2) without the fetch below. - // (Apple-ID-scoped, so an identity change / `reset()` doesn't require clearing it.) - activeSubscriptionGroupIds = computeActiveSubscriptionGroupIds(from: onDeviceSnapshot, storeProducts: []) - let purchasedProductIds = Set(onDeviceSnapshot.purchases.map { $0.id }) guard let storeProducts = try? await productsManager.products( @@ -242,10 +237,13 @@ actor ReceiptManager { forPaywall: nil, placement: nil ) else { + // Fetch failed: refresh from the snapshot alone so the set still reflects this load. + // We assign only *after* the await (here and below), never before, so a re-entrant + // `isFreeTrialAvailable` during the suspension can't observe a half-built set. + activeSubscriptionGroupIds = computeActiveSubscriptionGroupIds(from: onDeviceSnapshot, storeProducts: []) return } - // A successful fetch enriches the set with product-derived groups (StoreKit 1's source). activeSubscriptionGroupIds = computeActiveSubscriptionGroupIds(from: onDeviceSnapshot, storeProducts: storeProducts) await manager.loadIntroOfferEligibility(forProducts: storeProducts)