Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>
static var appTransactionId: String?
static var appId: UInt64?
/// Set from `AppTransaction.shared` when available (iOS 16+).
Expand All @@ -43,13 +48,15 @@ actor ReceiptManager {
receiptManager: ReceiptManagerType? = nil, // For testing
receiptDelegate: ReceiptDelegate?,
factory: Factory,
storage: Storage
storage: Storage,
activeSubscriptionGroupIds: Set<String> = [] // 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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<StoreProduct>
) -> Set<String> {
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)
}
Comment thread
pullfrog[bot] marked this conversation as resolved.

final class ReceiptRefreshDelegateWrapper: NSObject, SKRequestDelegate {
weak var receiptManager: ReceiptManager?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,34 +28,39 @@ 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<Purchase>
var transactionReceipts: [TransactionReceipt]
var latestSubscriptionPeriodType: LatestSubscription.PeriodType?
var latestSubscriptionWillAutoRenew: Bool?
var latestSubscriptionState: LatestSubscription.State?

init(
sk2IntroOfferEligibility: [String: Bool] = [:],
purchases: Set<Purchase> = [],
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<StoreProduct>) 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<StoreProduct>) async {}

func loadPurchases(serverEntitlementsByProductId: [String: Set<Entitlement>]) async -> PurchaseSnapshot {
var purchases: Set<Purchase> = []
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
}
8 changes: 8 additions & 0 deletions SuperwallKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -883,6 +885,7 @@
8708F74D80CB9A440F34A556 /* FakeContactsAuthorizationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeContactsAuthorizationStatus.swift; sourceTree = "<group>"; };
8719AC2E83128EE469E58C36 /* RedeemRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemRequest.swift; sourceTree = "<group>"; };
87AD727C5A8639E704F7BE98 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = "<group>"; };
87B1E659458AE78C3908562B /* SK2ReceiptManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2ReceiptManagerTests.swift; sourceTree = "<group>"; };
884DF3D8A1CA382BFEE9F8F4 /* ArchiveManifestUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveManifestUsage.swift; sourceTree = "<group>"; };
887834329A06971D86D5282F /* NotificationProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationProtocols.swift; sourceTree = "<group>"; };
88EBF6FC3090E004EE1377B4 /* PaywallViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerDelegate.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -944,6 +947,7 @@
9F72210F85864F3000F13646 /* ASN1Coder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASN1Coder.swift; sourceTree = "<group>"; };
A00C04BE1449BE21E13B61A0 /* LoggerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerMock.swift; sourceTree = "<group>"; };
A07DC8EA2AB02423AEE5DF26 /* EvaluationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationContext.swift; sourceTree = "<group>"; };
A08CC3D275A02927073952EB /* ReceiptManagerTrialEligibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptManagerTrialEligibilityTests.swift; sourceTree = "<group>"; };
A0B4279B992779CAD5A0694A /* AdServicesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdServicesResponse.swift; sourceTree = "<group>"; };
A116633D7246EB7BB7AF7229 /* ExpressionEvaluatorMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpressionEvaluatorMock.swift; sourceTree = "<group>"; };
A154A9E99D00B9BD8837B798 /* ExpressionLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpressionLogic.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1268,6 +1272,8 @@
9E3DAD767490972EA30257F9 /* EntitlementProcessorTests.swift */,
39B81D88316F06C0C2757F10 /* MockReceiptData.swift */,
03471273DF4C875227102BE2 /* ReceiptManagerTests.swift */,
A08CC3D275A02927073952EB /* ReceiptManagerTrialEligibilityTests.swift */,
87B1E659458AE78C3908562B /* SK2ReceiptManagerTests.swift */,
);
path = "Receipt Manager";
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
Loading
Loading