diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f9f1daa69..da3cf495fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +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 an intro offer eligibility mismatch between the paywall and the payment sheet when upgrading/crossgrading/downgrading. ## 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..69b0211061 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 @@ -230,19 +237,40 @@ 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 } + activeSubscriptionGroupIds = computeActiveSubscriptionGroupIds(from: onDeviceSnapshot, storeProducts: storeProducts) + 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. + /// 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 { - 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 + } + + // `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. @@ -323,6 +351,26 @@ actor ReceiptManager { } } +/// 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 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 { weak var receiptManager: ReceiptManager? 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..9c01fcf65d --- /dev/null +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/ReceiptManagerTrialEligibilityTests.swift @@ -0,0 +1,234 @@ +// +// 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 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 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, + activeSubscriptionGroupIds: Set + ) -> (manager: ReceiptManager, productsManager: ProductsManager) { + 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: dependencyContainer.storage, + activeSubscriptionGroupIds: activeSubscriptionGroupIds + ) + return (receiptManager, productsManager) + } + + private func makeProduct( + id: String = "com.app.gold", + subscriptionGroup: String? = "group_A" + ) -> StoreProduct { + return StoreProduct( + sk1Product: MockSkProduct( + productIdentifier: id, + subscriptionGroupIdentifier: subscriptionGroup + ) + ) + } + + @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, + activeSubscriptionGroupIds: ["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 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, + activeSubscriptionGroupIds: [] + ) + _ = 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, + 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 active subscriptions") + func trialWhenNoActiveSubscriptions() async { + let (manager, productsManager) = makeReceiptManager( + isEligibleForIntroOffer: true, + activeSubscriptionGroupIds: [] + ) + _ = 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 { + // Ineligible short-circuits before the active-subscription check. + let (manager, productsManager) = makeReceiptManager( + isEligibleForIntroOffer: false, + activeSubscriptionGroupIds: ["group_A"] + ) + _ = productsManager + let gold = makeProduct(id: "com.app.gold", subscriptionGroup: "group_A") + #expect(await manager.isFreeTrialAvailable(for: gold) == false) + } + + @Test("A product with no subscription group is unaffected by the active-group check") + func trialWhenProductHasNoSubscriptionGroup() async { + let (manager, productsManager) = makeReceiptManager( + isEligibleForIntroOffer: true, + activeSubscriptionGroupIds: ["group_A"] + ) + _ = productsManager + 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, +/// 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 + } +}