From 73ef1abf4e82d0c1e79ed095c5a2499e4b29d02e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:21:14 +0200 Subject: [PATCH 1/3] fix(entitlements): resolve each subscription group independently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An entitlement backed by more than one subscription group was resolved from a single most-recently-purchased transaction. Because `Transaction.subscriptionStatus` reports the state of that transaction's subscription group only, a refund in one group could be applied to the whole entitlement and deactivate a paid, active subscription in another. Whether it did depended purely on which group happened to hold the newest transaction, and the scalar fields were spliced across groups: `state` and `willRenew` from one, `expiresAt` from another. Transactions are now split into independent grant sources — one per subscription group, plus one for a lifetime purchase — each resolved on its own. The entitlement is active if any source grants it, and its scalar fields are taken from the source that is actually granting access (lifetime, then the active source with the most time left), falling back to the most recent purchase only when nothing is active. Each source's live status is now authoritative in both directions for its own group, so a lapsed subscription in its grace period is active rather than being judged inactive on its expiry date alone. Billing retry still defers to the dates, since it says nothing about whether the paid-for period has run out. `SubscriptionStatusProvider` is keyed on `EntitlementTransaction` instead of `StoreKit.Transaction` so the resolution can be tested without minting real StoreKit transactions, and statuses are cached per transaction so a group shared by several entitlements costs one lookup. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 + .../EntitlementProcessor.swift | 470 ++++++------ SuperwallKit.xcodeproj/project.pbxproj | 4 + .../EntitlementProcessorTests.swift | 62 +- .../PerGroupEntitlementResolutionTests.swift | 702 ++++++++++++++++++ 5 files changed, 1011 insertions(+), 229 deletions(-) create mode 100644 Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/PerGroupEntitlementResolutionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1880528a..81a628c7ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. - Fixes audiences matching users they shouldn't when you use a Purchase Controller. +- Fixes an entitlement being deactivated when one of the subscriptions granting it is refunded, even though another subscription in a different subscription group is still paid for and active. Each subscription group is now evaluated on its own. +- Fixes a subscription in its billing grace period being reported as inactive after its expiry date has passed. ## 4.16.3 diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift index 73a3cc683c..9924d5ba93 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift @@ -56,21 +56,43 @@ enum EntitlementTransactionType { case nonRenewable } -/// Protocol for providing subscription status information +/// The live subscription status of a single grant source, as reported by StoreKit. +struct ResolvedSubscriptionStatus: Sendable { + let state: LatestSubscription.State? + let willRenew: Bool + let offerType: LatestSubscription.OfferType? +} + +/// Protocol for providing subscription status information. +/// +/// Keyed on ``EntitlementTransaction`` rather than `StoreKit.Transaction` so the +/// resolution logic can be exercised without minting real StoreKit transactions. @available(iOS 15.0, *) protocol SubscriptionStatusProvider { - func getSubscriptionStatus(for transaction: Transaction) async -> StoreKit.Product.SubscriptionInfo.Status? - func getWillAutoRenew(from status: StoreKit.Product.SubscriptionInfo.Status?) -> Bool - func getSubscriptionState(from status: StoreKit.Product.SubscriptionInfo.Status?) -> LatestSubscription.State? - @available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) - func getOfferType(from transaction: Transaction) -> LatestSubscription.OfferType? + /// Resolves the live subscription status for the subscription group that + /// `transaction` belongs to, or `nil` when StoreKit has nothing to say about it. + func resolveStatus(for transaction: any EntitlementTransaction) async -> ResolvedSubscriptionStatus? } /// Default implementation using StoreKit directly @available(iOS 15.0, *) struct StoreKitSubscriptionStatusProvider: SubscriptionStatusProvider { - func getSubscriptionStatus(for transaction: Transaction) async -> StoreKit.Product.SubscriptionInfo.Status? { - return await transaction.subscriptionStatus + func resolveStatus(for transaction: any EntitlementTransaction) async -> ResolvedSubscriptionStatus? { + guard let transaction = transaction as? Transaction else { + return nil + } + let status = await transaction.subscriptionStatus + + var offerType: LatestSubscription.OfferType? + if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) { + offerType = getOfferType(from: transaction) + } + + return ResolvedSubscriptionStatus( + state: getSubscriptionState(from: status), + willRenew: getWillAutoRenew(from: status), + offerType: offerType + ) } func getWillAutoRenew(from status: StoreKit.Product.SubscriptionInfo.Status?) -> Bool { @@ -167,121 +189,182 @@ enum EntitlementProcessor { return (nonSubscriptions, subscriptions) } + // MARK: - Grant Sources + + /// One independent source of a grant for a single entitlement. + /// + /// Transactions within an App Store subscription group are mutually exclusive — + /// at most one is live at a time — but separate groups are independent of one + /// another, as is a lifetime purchase. Each source is therefore resolved on its + /// own and the entitlement is active if *any* source grants it. Resolving the + /// entitlement from a single most-recently-purchased transaction instead lets a + /// refund in one group cancel out a paid, active subscription in another. + struct GrantSource { + /// The transaction in this source with the greatest purchase date. Its + /// subscription group is the one queried for live status. + let representative: any EntitlementTransaction + let isLifetime: Bool + var isActive: Bool + var expiresAt: Date? + var renewedAt: Date? + var willRenew: Bool + var state: LatestSubscription.State? + var offerType: LatestSubscription.OfferType? + + var latestProductId: String { representative.productId } + var latestPurchaseDate: Date { representative.purchaseDate } + } + + /// Splits an entitlement's transactions into independent grant sources, + /// resolving each one from the transaction dates alone. + static func grantSources( + for transactions: [any EntitlementTransaction] + ) -> [GrantSource] { + let now = Date() + let lifetimeKey = "lifetime" + var bucketKeys: [String] = [] + var buckets: [String: [any EntitlementTransaction]] = [:] + + for transaction in transactions { + let key: String + + switch transaction.entitlementProductType { + case .nonConsumable: + // A revoked lifetime purchase grants nothing. + if transaction.isRevoked { + continue + } + key = lifetimeKey + case .autoRenewable, + .nonRenewable: + // Products in the same subscription group replace one another, so they + // resolve together. Anything without a group — non-renewing + // subscriptions, StoreKit 1 — stands alone under its product ID. + key = transaction.subscriptionGroupId.map { "group:\($0)" } ?? "product:\(transaction.productId)" + case .consumable: + // Consumables never grant an entitlement. + continue + } + + if buckets[key] == nil { + bucketKeys.append(key) + } + buckets[key, default: []].append(transaction) + } + + return bucketKeys.compactMap { key -> GrantSource? in + guard let bucket = buckets[key], + let representative = bucket.max(by: { $0.purchaseDate < $1.purchaseDate }) else { + return nil + } + let isLifetime = key == lifetimeKey + let unrevoked = bucket.filter { !$0.isRevoked } + + return GrantSource( + representative: representative, + isLifetime: isLifetime, + // A lifetime purchase never expires. Everything else grants access for + // as long as an unrevoked transaction still has time left on it. + isActive: isLifetime || unrevoked.contains { ($0.expirationDate ?? .distantPast) > now }, + expiresAt: isLifetime ? nil : unrevoked.compactMap(\.expirationDate).max(), + renewedAt: unrevoked + .filter { $0.entitlementProductType == .autoRenewable && $0.originalPurchaseDate < $0.purchaseDate } + .map(\.purchaseDate) + .max(), + willRenew: representative.willRenew, + state: nil, + offerType: nil + ) + } + } + + /// Picks the source that describes the entitlement's scalar fields. + /// + /// Prefers whatever is actually granting access — a lifetime purchase, then the + /// active source with the most time left on it — so `state`, `willRenew`, + /// `expiresAt` and `latestProductId` all describe the same subscription. Only + /// when nothing is active does it fall back to the most recent purchase, so a + /// lapsed entitlement still reports its last known state. + static func representativeSource(from sources: [GrantSource]) -> GrantSource? { + if let lifetime = sources.first(where: { $0.isLifetime && $0.isActive }) { + return lifetime + } + + let activeSources = sources.filter { $0.isActive } + + if activeSources.isEmpty { + return sources.max { $0.latestPurchaseDate < $1.latestPurchaseDate } + } + + return activeSources.max { + ($0.expiresAt ?? .distantFuture) < ($1.expiresAt ?? .distantFuture) + } + } + /// Process entitlements from transactions, enriching them with metadata static func buildEntitlementsFromTransactions( from transactionsByEntitlement: [String: [any EntitlementTransaction]], rawEntitlementsByProductId: [String: Set], productIdsByEntitlementId: [String: Set] ) -> [String: Set] { - var processedEntitlementsByProductId: [String: Set] = [:] - var mostRecentRenewableByEntitlement: [String: (any EntitlementTransaction)] = [:] - let now = Date() - - // Process each entitlement group - for (entitlementId, transactions) in transactionsByEntitlement { - var isActive = false - var renewedAt: Date? - var expiresAt: Date? - var mostRecentRenewable: (any EntitlementTransaction)? - var latestProductId: String? - - let startsAt = transactions.last?.originalPurchaseDate - var isLifetime = false - - // Check for lifetime products (non-consumable, non-revoked) - if let lifetimeTransaction = transactions.first(where: { - $0.entitlementProductType == .nonConsumable && !$0.isRevoked - }) { - isLifetime = true - latestProductId = lifetimeTransaction.productId - isActive = true - } - - // Process transactions to determine active status and dates - for transaction in transactions { - // Check if transaction is active (non-revoked and not expired) - if !transaction.isRevoked { - if let expirationDate = transaction.expirationDate { - if expirationDate > now { - isActive = true - } - } - } - - // Track most recent renewable transaction - if !isLifetime, - transaction.entitlementProductType == .autoRenewable || transaction.entitlementProductType == .nonRenewable { - if let mostRecent = mostRecentRenewable { - if mostRecent.purchaseDate < transaction.purchaseDate { - mostRecentRenewable = transaction - } - } else { - mostRecentRenewable = transaction - } - } + let sourcesByEntitlement = transactionsByEntitlement.mapValues { grantSources(for: $0) } - // Track renewal date - if transaction.entitlementProductType == .autoRenewable, - !transaction.isRevoked { - if transaction.originalPurchaseDate < transaction.purchaseDate, - renewedAt == nil || renewedAt ?? Date() < transaction.purchaseDate { - renewedAt = transaction.purchaseDate - } - } + return buildEntitlements( + from: transactionsByEntitlement, + sourcesByEntitlement: sourcesByEntitlement, + rawEntitlementsByProductId: rawEntitlementsByProductId, + productIdsByEntitlementId: productIdsByEntitlementId + ) + } - // Track latest expiration for non-lifetime - if !isLifetime, - transaction.entitlementProductType == .autoRenewable || transaction.entitlementProductType == .nonRenewable, - !transaction.isRevoked, - let expiration = transaction.expirationDate { - if let currentExpiresAt = expiresAt { - if currentExpiresAt < expiration { - expiresAt = expiration - } - } else { - expiresAt = expiration - } - } - } + /// Enriches the raw entitlements using already-resolved grant sources. + private static func buildEntitlements( + from transactionsByEntitlement: [String: [any EntitlementTransaction]], + sourcesByEntitlement: [String: [GrantSource]], + rawEntitlementsByProductId: [String: Set], + productIdsByEntitlementId: [String: Set] + ) -> [String: Set] { + var processedEntitlementsByProductId: [String: Set] = [:] - if latestProductId == nil { - latestProductId = mostRecentRenewable?.productId - } + for (entitlementId, transactions) in transactionsByEntitlement { + let sources = sourcesByEntitlement[entitlementId] ?? [] - // Store the most recent renewable for this entitlement (only if it exists) - if let mostRecentRenewable = mostRecentRenewable { - mostRecentRenewableByEntitlement[entitlementId] = mostRecentRenewable - } + // One source's refund or expiry can never cancel another's grant, so the + // entitlement is active if any source grants it. + let isActive = sources.contains { $0.isActive } + let grantingSource = representativeSource(from: sources) + let startsAt = transactions.map(\.originalPurchaseDate).min() // Find all product IDs for this entitlement from server config let productIds = productIdsByEntitlementId[entitlementId] ?? [] for productId in productIds { // Get the raw entitlement info for this product - if let rawEntitlements = rawEntitlementsByProductId[productId] { - var enrichedEntitlements: Set = [] - - for rawEntitlement in rawEntitlements where rawEntitlement.id == entitlementId { - let enrichedEntitlement = Entitlement( - id: rawEntitlement.id, - type: rawEntitlement.type, - isActive: isActive, - productIds: productIds, - latestProductId: latestProductId, - store: .appStore, - startsAt: startsAt, - renewedAt: renewedAt, - expiresAt: expiresAt, - isLifetime: isLifetime, - willRenew: mostRecentRenewable?.willRenew ?? false, - state: nil, // Will be set separately if needed - offerType: nil // Will be set separately if needed - ) - enrichedEntitlements.insert(enrichedEntitlement) - } - - processedEntitlementsByProductId[productId, default: []].formUnion(enrichedEntitlements) + guard let rawEntitlements = rawEntitlementsByProductId[productId] else { + continue + } + var enrichedEntitlements: Set = [] + + for rawEntitlement in rawEntitlements where rawEntitlement.id == entitlementId { + let enrichedEntitlement = Entitlement( + id: rawEntitlement.id, + type: rawEntitlement.type, + isActive: isActive, + productIds: productIds, + latestProductId: grantingSource?.latestProductId, + store: .appStore, + startsAt: startsAt, + renewedAt: grantingSource?.renewedAt, + expiresAt: grantingSource?.expiresAt, + isLifetime: grantingSource?.isLifetime ?? false, + willRenew: grantingSource?.willRenew ?? false, + state: grantingSource?.state, + offerType: grantingSource?.offerType + ) + enrichedEntitlements.insert(enrichedEntitlement) } + + processedEntitlementsByProductId[productId, default: []].formUnion(enrichedEntitlements) } } @@ -312,127 +395,92 @@ enum EntitlementProcessor { enableExperimentalDeviceVariables: Bool = false, onLatestSubscriptionUpdate: ((LatestSubscription.State?, Bool?, LatestSubscription.OfferType?) -> Void)? = nil ) async -> [String: Set] { - // First, do the basic processing and build mostRecentRenewable lookup - let basicEntitlementsByProductId = buildEntitlementsFromTransactions( - from: transactionsByEntitlement, - rawEntitlementsByProductId: rawEntitlementsByProductId, - productIdsByEntitlementId: productIdsByEntitlementId - ) - var finalEntitlementsByProductId = basicEntitlementsByProductId + var sourcesByEntitlement: [String: [GrantSource]] = [:] + var updatedSubscriptions = subscriptions - // Build mostRecentRenewable lookup for enhancement phase - var mostRecentRenewableByEntitlement: [String: (any EntitlementTransaction)] = [:] - for (entitlementId, transactions) in transactionsByEntitlement { - var mostRecentRenewable: (any EntitlementTransaction)? - let isLifetime = transactions.contains { $0.entitlementProductType == .nonConsumable && !$0.isRevoked } - - if !isLifetime { - for transaction in transactions { - if transaction.entitlementProductType == .autoRenewable || transaction.entitlementProductType == .nonRenewable { - if let mostRecent = mostRecentRenewable { - if mostRecent.purchaseDate < transaction.purchaseDate { - mostRecentRenewable = transaction - } - } else { - mostRecentRenewable = transaction - } - } - } - } - - if let mostRecentRenewable = mostRecentRenewable { - mostRecentRenewableByEntitlement[entitlementId] = mostRecentRenewable - } - } + // The same subscription group can back several entitlements. Cached by + // transaction ID so each group costs one status lookup rather than one per + // entitlement it grants. The value is itself optional, so an unwrapped + // `cached` here is a recorded "StoreKit had nothing to say". + var statusCache: [String: ResolvedSubscriptionStatus?] = [:] - // Then enhance with subscription status for StoreKit transactions for (entitlementId, transactions) in transactionsByEntitlement { - let isLifetime = transactions.contains { $0.entitlementProductType == .nonConsumable && !$0.isRevoked } + var sources = grantSources(for: transactions) - // one subscriptionStatus call per entitlement - var willRenew = mostRecentRenewableByEntitlement[entitlementId]?.willRenew ?? false - var state: LatestSubscription.State? - var offerType: LatestSubscription.OfferType? - - let subscriptionTxnIndex: Array.Index? - if let renewable = mostRecentRenewableByEntitlement[entitlementId] { - subscriptionTxnIndex = subscriptions.firstIndex { - $0.transactionId == renewable.transactionId + for index in sources.indices { + // A lifetime purchase has no subscription group to ask about. + if sources[index].isLifetime { + continue } - } else { - subscriptionTxnIndex = nil - } - - if !isLifetime, - let renewableTransaction = mostRecentRenewableByEntitlement[entitlementId] as? Transaction { - let status = await subscriptionStatusProvider.getSubscriptionStatus(for: renewableTransaction) - - willRenew = subscriptionStatusProvider.getWillAutoRenew(from: status) - - if let index = subscriptionTxnIndex { - subscriptions[index].willRenew = willRenew + let representative = sources[index].representative + + let resolvedStatus: ResolvedSubscriptionStatus? + if let cached = statusCache[representative.transactionId] { + resolvedStatus = cached + } else { + resolvedStatus = await subscriptionStatusProvider.resolveStatus(for: representative) + statusCache[representative.transactionId] = resolvedStatus } - state = subscriptionStatusProvider.getSubscriptionState(from: status) - - if let index = subscriptionTxnIndex { - subscriptions[index].isInGracePeriod = state == .inGracePeriod - subscriptions[index].isInBillingRetryPeriod = state == .inBillingRetryPeriod + guard let status = resolvedStatus else { + continue } - if #available(iOS 17.2, visionOS 1.1, *) { - offerType = subscriptionStatusProvider.getOfferType(from: renewableTransaction) + sources[index].willRenew = status.willRenew + sources[index].state = status.state + sources[index].offerType = status.offerType + + // The subscription-level state is authoritative for the group it + // describes — and only for that group. `Transaction.all` can hold a + // transaction with no revocation date or a future expiry even though the + // subscription as a whole has been revoked or has lapsed, and it holds + // nothing at all to show that a lapsed subscription is in its grace + // period. + switch status.state { + case .subscribed, + .inGracePeriod: + sources[index].isActive = true + case .revoked, + .expired: + sources[index].isActive = false + case .inBillingRetryPeriod, + nil: + // Billing retry says nothing about whether the paid-for period has run + // out yet, so the dates stay in charge. + break } - // Call the callback for experimental device variables - onLatestSubscriptionUpdate?(state, willRenew, offerType) + if let subscriptionIndex = updatedSubscriptions.firstIndex( + where: { $0.transactionId == representative.transactionId } + ) { + updatedSubscriptions[subscriptionIndex].willRenew = status.willRenew + updatedSubscriptions[subscriptionIndex].isInGracePeriod = status.state == .inGracePeriod + updatedSubscriptions[subscriptionIndex].isInBillingRetryPeriod = status.state == .inBillingRetryPeriod + } } - // Update processed entitlements with subscription-specific data - let productIds = productIdsByEntitlementId[entitlementId] ?? [] - for productId in productIds { - if let entitlements = finalEntitlementsByProductId[productId] { - var updatedEntitlements: Set = [] - for entitlement in entitlements where entitlement.id == entitlementId { - // The subscription-level state from subscriptionStatus is - // authoritative. The first pass may incorrectly compute - // isActive = true when Transaction.all contains a transaction - // without revocationDate or with a future expirationDate, even - // though the subscription as a whole is revoked or expired. - let resolvedIsActive: Bool - if state == .revoked || state == .expired { - resolvedIsActive = false - } else { - resolvedIsActive = entitlement.isActive - } - - let updatedEntitlement = Entitlement( - id: entitlement.id, - type: entitlement.type, - isActive: resolvedIsActive, - productIds: entitlement.productIds, - latestProductId: entitlement.latestProductId, - store: entitlement.store, - startsAt: entitlement.startsAt, - renewedAt: entitlement.renewedAt, - expiresAt: entitlement.expiresAt, - isLifetime: entitlement.isLifetime, - willRenew: willRenew, - state: state, - offerType: offerType - ) - updatedEntitlements.insert(updatedEntitlement) - } - // Add back other entitlements for this product - for other in entitlements where other.id != entitlementId { - updatedEntitlements.insert(other) - } - finalEntitlementsByProductId[productId] = updatedEntitlements - } + sourcesByEntitlement[entitlementId] = sources + + // Report the source that actually describes the entitlement rather than + // whichever group happened to be resolved last. + if let grantingSource = representativeSource(from: sources), + !grantingSource.isLifetime { + onLatestSubscriptionUpdate?( + grantingSource.state, + grantingSource.willRenew, + grantingSource.offerType + ) } } - return finalEntitlementsByProductId + subscriptions = updatedSubscriptions + + return buildEntitlements( + from: transactionsByEntitlement, + sourcesByEntitlement: sourcesByEntitlement, + rawEntitlementsByProductId: rawEntitlementsByProductId, + productIdsByEntitlementId: productIdsByEntitlementId + ) } } diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index a3bcbd6058..c3f1878d7e 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -486,6 +486,7 @@ D6CA719D79BAA6369D1C01C3 /* AudienceLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71CF4BE5D2A6CEA9F8993C81 /* AudienceLogic.swift */; }; D77DB3187C62B91E3D55DF80 /* ArchiveRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4300EBF7463A42D2FB89371 /* ArchiveRequest.swift */; }; D7F5A91A1E37E6BFB84E5609 /* StoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10310294FD27EB6A0621341 /* StoreProduct.swift */; }; + D824309A73F44326E1E0681E /* PerGroupEntitlementResolutionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC43181BC61F116C4FD06657 /* PerGroupEntitlementResolutionTests.swift */; }; D89E9C69317044050B97B573 /* IdentityManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2714D9FD9F55611B4C5E4E7D /* IdentityManagerMock.swift */; }; D89F615A7826947C9246F6B1 /* WebArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = E72593E1D4123B176EC83499 /* WebArchive.swift */; }; D90B2915CA23976F48794449 /* CustomStoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0704117EF9F98A047714162B /* CustomStoreTransaction.swift */; }; @@ -1004,6 +1005,7 @@ ABC1253C6D5DD8D967BE05D1 /* LocalizationGrouping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationGrouping.swift; sourceTree = ""; }; ABD045A5C4A47B1CA9365285 /* MicrophonePermissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrophonePermissionTests.swift; sourceTree = ""; }; AC11F0D5B6B8A0F5EC5D200B /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; + AC43181BC61F116C4FD06657 /* PerGroupEntitlementResolutionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGroupEntitlementResolutionTests.swift; sourceTree = ""; }; AC74F16DC5A17489E97061EA /* PushTransitionLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushTransitionLogic.swift; sourceTree = ""; }; AD23BBF9EA198ED8798A8F62 /* SystemInfo+NotificationName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemInfo+NotificationName.swift"; sourceTree = ""; }; AE32816F1CA1637897AC87A2 /* UNUserNotificationCenter+SuperwallNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UNUserNotificationCenter+SuperwallNotifications.swift"; sourceTree = ""; }; @@ -1303,6 +1305,7 @@ children = ( 9E3DAD767490972EA30257F9 /* EntitlementProcessorTests.swift */, 39B81D88316F06C0C2757F10 /* MockReceiptData.swift */, + AC43181BC61F116C4FD06657 /* PerGroupEntitlementResolutionTests.swift */, 03471273DF4C875227102BE2 /* ReceiptManagerTests.swift */, A08CC3D275A02927073952EB /* ReceiptManagerTrialEligibilityTests.swift */, 87B1E659458AE78C3908562B /* SK2ReceiptManagerTests.swift */, @@ -3381,6 +3384,7 @@ 4B0E203D477E48611797047C /* PaywallViewControllerCacheTests.swift in Sources */, 7CC56E289C0A1C93411B68D2 /* PaywallViewControllerDrawerTests.swift in Sources */, 5F1F480AA12C8D17B179D96B /* PaywallViewControllerMock.swift in Sources */, + D824309A73F44326E1E0681E /* PerGroupEntitlementResolutionTests.swift in Sources */, ED246150DA2747AA42D6009C /* PermissionStatusTests.swift in Sources */, 4A4E5413A8753AFB624D325D /* PermissionTypeTests.swift in Sources */, EA66951B1DF341C4F0448C9F /* PlacementsQueueTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/EntitlementProcessorTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/EntitlementProcessorTests.swift index be251bb328..4249a8c4ff 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/EntitlementProcessorTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/EntitlementProcessorTests.swift @@ -1340,35 +1340,61 @@ struct EntitlementProcessorTests { @available(iOS 15.0, *) struct MockSubscriptionStatusProvider: SubscriptionStatusProvider { - var mockWillAutoRenew: Bool - var mockState: LatestSubscription.State? - var mockOfferType: LatestSubscription.OfferType? + /// Statuses keyed by subscription group ID, for tests that need each group to + /// answer differently. Anything not listed falls back to `defaultStatus`. + var statusesByGroupId: [String: ResolvedSubscriptionStatus] + var defaultStatus: ResolvedSubscriptionStatus? + + /// Records the transactions the provider was asked about, so tests can assert + /// each subscription group is resolved on its own. + let resolvedTransactionIds = Recorder() + + final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var ids: [String] = [] + + var all: [String] { + lock.lock() + defer { lock.unlock() } + return ids + } + + func record(_ id: String) { + lock.lock() + defer { lock.unlock() } + ids.append(id) + } + } init( mockWillAutoRenew: Bool = true, mockState: LatestSubscription.State? = .subscribed, mockOfferType: LatestSubscription.OfferType? = nil ) { - self.mockWillAutoRenew = mockWillAutoRenew - self.mockState = mockState - self.mockOfferType = mockOfferType - } - - func getSubscriptionStatus(for transaction: Transaction) async -> StoreKit.Product.SubscriptionInfo.Status? { - return nil // Simplified for testing + self.statusesByGroupId = [:] + self.defaultStatus = ResolvedSubscriptionStatus( + state: mockState, + willRenew: mockWillAutoRenew, + offerType: mockOfferType + ) } - func getWillAutoRenew(from status: StoreKit.Product.SubscriptionInfo.Status?) -> Bool { - return mockWillAutoRenew + init( + statusesByGroupId: [String: ResolvedSubscriptionStatus], + defaultStatus: ResolvedSubscriptionStatus? = nil + ) { + self.statusesByGroupId = statusesByGroupId + self.defaultStatus = defaultStatus } - func getSubscriptionState(from status: StoreKit.Product.SubscriptionInfo.Status?) -> LatestSubscription.State? { - return mockState - } + func resolveStatus(for transaction: any EntitlementTransaction) async -> ResolvedSubscriptionStatus? { + resolvedTransactionIds.record(transaction.transactionId) - @available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) - func getOfferType(from transaction: Transaction) -> LatestSubscription.OfferType? { - return mockOfferType + if let groupId = transaction.subscriptionGroupId, + let status = statusesByGroupId[groupId] { + return status + } + return defaultStatus } } diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/PerGroupEntitlementResolutionTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/PerGroupEntitlementResolutionTests.swift new file mode 100644 index 0000000000..c58cd2ff45 --- /dev/null +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/PerGroupEntitlementResolutionTests.swift @@ -0,0 +1,702 @@ +// +// PerGroupEntitlementResolutionTests.swift +// SuperwallKitTests +// +// Created by Claude on 03/09/2026. +// + +import Testing +import Foundation +import StoreKit +@testable import SuperwallKit + +/// Covers entitlements that are granted by more than one independent source — +/// two subscription groups, or a subscription alongside a lifetime purchase. +/// Each source has to resolve on its own: a refund in one group must not cancel +/// out a paid, active subscription in another. +@Suite("Per-group entitlement resolution") +struct PerGroupEntitlementResolutionTests { + // MARK: - Helpers + + private struct MockTransaction: EntitlementTransaction { + let productId: String + let transactionId: String + let purchaseDate: Date + let originalPurchaseDate: Date + let expirationDate: Date? + let isRevoked: Bool + let entitlementProductType: EntitlementTransactionType + let willRenew: Bool + let renewedAt: Date? + let isInGracePeriod: Bool + let isInBillingRetryPeriod: Bool + let isActive: Bool + let offerType: LatestSubscription.OfferType? + let subscriptionGroupId: String? + } + + private func makeTransaction( + productId: String, + transactionId: String, + subscriptionGroupId: String?, + purchaseDate: Date, + originalPurchaseDate: Date? = nil, + expirationDate: Date? = nil, + isRevoked: Bool = false, + productType: EntitlementTransactionType = .autoRenewable, + willRenew: Bool = true + ) -> MockTransaction { + return MockTransaction( + productId: productId, + transactionId: transactionId, + purchaseDate: purchaseDate, + originalPurchaseDate: originalPurchaseDate ?? purchaseDate, + expirationDate: expirationDate, + isRevoked: isRevoked, + entitlementProductType: productType, + willRenew: willRenew, + renewedAt: nil, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: !isRevoked && (expirationDate ?? .distantPast) > Date(), + offerType: nil, + subscriptionGroupId: subscriptionGroupId + ) + } + + private func makeEntitlement( + id: String = "premium", + productIds: Set + ) -> Entitlement { + return Entitlement( + id: id, + type: .serviceLevel, + isActive: false, + productIds: productIds + ) + } + + private func fixtures( + for products: Set, + entitlementId: String = "premium" + ) -> (raw: [String: Set], productIds: [String: Set]) { + let entitlement = makeEntitlement(id: entitlementId, productIds: products) + var raw: [String: Set] = [:] + for product in products { + raw[product] = Set([entitlement]) + } + return (raw, [entitlementId: products]) + } + + // MARK: - Grant source partitioning + + @Test("Products in the same subscription group resolve as one source") + func sameGroupIsOneSource() { + let baseDate = Date() + let transactions: [any EntitlementTransaction] = [ + makeTransaction( + productId: "monthly", + transactionId: "txn_1", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-7200), + expirationDate: baseDate.addingTimeInterval(-3600) + ), + makeTransaction( + productId: "yearly", + transactionId: "txn_2", + subscriptionGroupId: "group_1", + purchaseDate: baseDate, + expirationDate: baseDate.addingTimeInterval(3600) + ) + ] + + let sources = EntitlementProcessor.grantSources(for: transactions) + + #expect(sources.count == 1) + #expect(sources.first?.isActive == true) + // The upgrade replaced the monthly plan, so the source describes the yearly one. + #expect(sources.first?.latestProductId == "yearly") + #expect(sources.first?.expiresAt == baseDate.addingTimeInterval(3600)) + } + + @Test("Separate subscription groups resolve as separate sources") + func separateGroupsAreSeparateSources() { + let baseDate = Date() + let transactions: [any EntitlementTransaction] = [ + makeTransaction( + productId: "monthly", + transactionId: "txn_1", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-7200), + expirationDate: baseDate.addingTimeInterval(3600) + ), + makeTransaction( + productId: "yearly", + transactionId: "txn_2", + subscriptionGroupId: "group_2", + purchaseDate: baseDate, + expirationDate: baseDate.addingTimeInterval(7200) + ) + ] + + let sources = EntitlementProcessor.grantSources(for: transactions) + + #expect(sources.count == 2) + #expect(sources.allSatisfy { $0.isActive }) + } + + @Test("Transactions with no subscription group stand alone per product") + func missingGroupIdFallsBackToProductId() { + let baseDate = Date() + let transactions: [any EntitlementTransaction] = [ + makeTransaction( + productId: "pass_a", + transactionId: "txn_1", + subscriptionGroupId: nil, + purchaseDate: baseDate, + expirationDate: baseDate.addingTimeInterval(3600), + productType: .nonRenewable + ), + makeTransaction( + productId: "pass_b", + transactionId: "txn_2", + subscriptionGroupId: nil, + purchaseDate: baseDate, + expirationDate: baseDate.addingTimeInterval(7200), + productType: .nonRenewable + ) + ] + + let sources = EntitlementProcessor.grantSources(for: transactions) + + #expect(sources.count == 2) + } + + // MARK: - The refund-across-groups scenario + + @Test("A refund in one group leaves the other group's grant intact") + func refundInOneGroupDoesNotCancelAnother() async { + let baseDate = Date() + + // Bought first, stalled in billing retry, then refunded. The refund revokes + // the transaction but leaves its expiry in the future. + let refunded = makeTransaction( + productId: "monthly", + transactionId: "txn_refunded", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-7200), + expirationDate: baseDate.addingTimeInterval(1800), + isRevoked: true, + willRenew: false + ) + + // Bought while the first plan was stuck. Still paid for and active. + let paid = makeTransaction( + productId: "yearly", + transactionId: "txn_paid", + subscriptionGroupId: "group_2", + purchaseDate: baseDate.addingTimeInterval(-3600), + expirationDate: baseDate.addingTimeInterval(86_400) + ) + + let (raw, productIds) = fixtures(for: ["monthly", "yearly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [refunded, paid]) + + let provider = MockSubscriptionStatusProvider( + statusesByGroupId: [ + "group_1": ResolvedSubscriptionStatus(state: .revoked, willRenew: false, offerType: nil), + "group_2": ResolvedSubscriptionStatus(state: .subscribed, willRenew: true, offerType: nil) + ] + ) + + let result = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [refunded, paid]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) + + let entitlement = result["yearly"]?.first + #expect(entitlement?.isActive == true) + // The scalars describe the subscription that is actually granting access. + #expect(entitlement?.latestProductId == "yearly") + #expect(entitlement?.state == .subscribed) + #expect(entitlement?.willRenew == true) + #expect(entitlement?.expiresAt == baseDate.addingTimeInterval(86_400)) + + // Both products report the same entitlement. + #expect(result["monthly"]?.first == entitlement) + + // Each group was resolved on its own rather than one standing in for both. + #expect(Set(provider.resolvedTransactionIds.all) == Set(["txn_refunded", "txn_paid"])) + } + + @Test("A refunded group stays harmless even when it holds the newest purchase") + func refundedGroupWithNewestPurchaseDoesNotCancelAnother() async { + let baseDate = Date() + + // The yearly plan was bought first... + let paid = makeTransaction( + productId: "yearly", + transactionId: "txn_paid", + subscriptionGroupId: "group_2", + purchaseDate: baseDate.addingTimeInterval(-7200), + expirationDate: baseDate.addingTimeInterval(86_400) + ) + + // ...then the other group recovered from billing retry and renewed *after* + // it, and only then was refunded. This is the ordering that makes a + // most-recent-transaction rule pick the revoked group. + let refunded = makeTransaction( + productId: "monthly", + transactionId: "txn_refunded", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-60), + originalPurchaseDate: baseDate.addingTimeInterval(-100_000), + expirationDate: baseDate.addingTimeInterval(1800), + isRevoked: true, + willRenew: false + ) + + let (raw, productIds) = fixtures(for: ["monthly", "yearly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [paid, refunded]) + + let provider = MockSubscriptionStatusProvider( + statusesByGroupId: [ + "group_1": ResolvedSubscriptionStatus(state: .revoked, willRenew: false, offerType: nil), + "group_2": ResolvedSubscriptionStatus(state: .subscribed, willRenew: true, offerType: nil) + ] + ) + + let result = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [paid, refunded]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) + + let entitlement = result["yearly"]?.first + #expect(entitlement?.isActive == true) + #expect(entitlement?.latestProductId == "yearly") + #expect(entitlement?.state == .subscribed) + #expect(entitlement?.willRenew == true) + #expect(entitlement?.expiresAt == baseDate.addingTimeInterval(86_400)) + } + + @Test("A refund across groups is caught without live status, from dates alone") + func refundAcrossGroupsResolvesFromDatesAlone() { + let baseDate = Date() + + // The revoked transaction holds the furthest expiry and the newest purchase. + let refunded = makeTransaction( + productId: "monthly", + transactionId: "txn_refunded", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-60), + expirationDate: baseDate.addingTimeInterval(200_000), + isRevoked: true, + willRenew: false + ) + let paid = makeTransaction( + productId: "yearly", + transactionId: "txn_paid", + subscriptionGroupId: "group_2", + purchaseDate: baseDate.addingTimeInterval(-7200), + expirationDate: baseDate.addingTimeInterval(86_400) + ) + + let (raw, productIds) = fixtures(for: ["monthly", "yearly"]) + + let result = EntitlementProcessor.buildEntitlementsFromTransactions( + from: ["premium": [refunded, paid]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds + ) + + let entitlement = result["yearly"]?.first + #expect(entitlement?.isActive == true) + #expect(entitlement?.latestProductId == "yearly") + // The revoked transaction's expiry must not leak into the entitlement. + #expect(entitlement?.expiresAt == baseDate.addingTimeInterval(86_400)) + } + + @Test("Every group revoked leaves the entitlement inactive") + func allGroupsRevokedIsInactive() async { + let baseDate = Date() + let first = makeTransaction( + productId: "monthly", + transactionId: "txn_1", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-7200), + expirationDate: baseDate.addingTimeInterval(1800), + isRevoked: true, + willRenew: false + ) + let second = makeTransaction( + productId: "yearly", + transactionId: "txn_2", + subscriptionGroupId: "group_2", + purchaseDate: baseDate.addingTimeInterval(-3600), + expirationDate: baseDate.addingTimeInterval(86_400), + isRevoked: true, + willRenew: false + ) + + let (raw, productIds) = fixtures(for: ["monthly", "yearly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [first, second]) + + let provider = MockSubscriptionStatusProvider( + mockWillAutoRenew: false, + mockState: .revoked + ) + + let result = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [first, second]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) + + let entitlement = result["yearly"]?.first + #expect(entitlement?.isActive == false) + #expect(entitlement?.state == .revoked) + // With nothing granting access, the scalars fall back to the most recent + // purchase so the entitlement still reports its last known state. + #expect(entitlement?.latestProductId == "yearly") + } + + @Test("A revoked subscription cannot cancel a lifetime purchase") + func lifetimeSurvivesRevokedSubscription() async { + let baseDate = Date() + let lifetime = makeTransaction( + productId: "lifetime", + transactionId: "txn_lifetime", + subscriptionGroupId: nil, + purchaseDate: baseDate.addingTimeInterval(-100_000), + productType: .nonConsumable, + willRenew: false + ) + let refunded = makeTransaction( + productId: "monthly", + transactionId: "txn_refunded", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-60), + expirationDate: baseDate.addingTimeInterval(1800), + isRevoked: true, + willRenew: false + ) + + let (raw, productIds) = fixtures(for: ["lifetime", "monthly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [lifetime, refunded]) + + let provider = MockSubscriptionStatusProvider( + mockWillAutoRenew: false, + mockState: .revoked + ) + + let result = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [lifetime, refunded]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) + + let entitlement = result["lifetime"]?.first + #expect(entitlement?.isActive == true) + #expect(entitlement?.isLifetime == true) + #expect(entitlement?.latestProductId == "lifetime") + // A lifetime purchase has no subscription group, so its status is never + // queried and the revoked group's state must not be stamped onto it. + #expect(entitlement?.state == nil) + #expect(provider.resolvedTransactionIds.all == ["txn_refunded"]) + } + + // MARK: - Grace period + + @Test("A lapsed subscription in its grace period is still active") + func gracePeriodIsActive() async { + let baseDate = Date() + + // In a grace period the expiry has already passed — only the live status + // says the person still has access. + let lapsed = makeTransaction( + productId: "monthly", + transactionId: "txn_grace", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-86_400), + expirationDate: baseDate.addingTimeInterval(-60) + ) + + let (raw, productIds) = fixtures(for: ["monthly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [lapsed]) + + let provider = MockSubscriptionStatusProvider( + mockWillAutoRenew: true, + mockState: .inGracePeriod + ) + + let result = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [lapsed]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) + + let entitlement = result["monthly"]?.first + #expect(entitlement?.isActive == true) + #expect(entitlement?.state == .inGracePeriod) + #expect(subscriptions.first?.isInGracePeriod == true) + } + + @Test("A grace period in one group keeps the entitlement active on its own") + func gracePeriodGrantsAlongsideAnExpiredGroup() async { + let baseDate = Date() + let expired = makeTransaction( + productId: "yearly", + transactionId: "txn_expired", + subscriptionGroupId: "group_2", + purchaseDate: baseDate.addingTimeInterval(-100_000), + expirationDate: baseDate.addingTimeInterval(-1000), + willRenew: false + ) + let inGrace = makeTransaction( + productId: "monthly", + transactionId: "txn_grace", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-86_400), + expirationDate: baseDate.addingTimeInterval(-60) + ) + + let (raw, productIds) = fixtures(for: ["monthly", "yearly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [expired, inGrace]) + + let provider = MockSubscriptionStatusProvider( + statusesByGroupId: [ + "group_1": ResolvedSubscriptionStatus(state: .inGracePeriod, willRenew: true, offerType: nil), + "group_2": ResolvedSubscriptionStatus(state: .expired, willRenew: false, offerType: nil) + ] + ) + + let result = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [expired, inGrace]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) + + let entitlement = result["monthly"]?.first + #expect(entitlement?.isActive == true) + #expect(entitlement?.state == .inGracePeriod) + #expect(entitlement?.latestProductId == "monthly") + } + + @Test("An expired group cannot cancel a subscribed group") + func expiredGroupDoesNotCancelSubscribedGroup() async { + let baseDate = Date() + let expired = makeTransaction( + productId: "monthly", + transactionId: "txn_expired", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-60), + expirationDate: baseDate.addingTimeInterval(3600), + willRenew: false + ) + let subscribed = makeTransaction( + productId: "yearly", + transactionId: "txn_subscribed", + subscriptionGroupId: "group_2", + purchaseDate: baseDate.addingTimeInterval(-7200), + expirationDate: baseDate.addingTimeInterval(86_400) + ) + + let (raw, productIds) = fixtures(for: ["monthly", "yearly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [expired, subscribed]) + + let provider = MockSubscriptionStatusProvider( + statusesByGroupId: [ + // StoreKit says this group has lapsed even though the transaction's own + // expiry is still in the future. + "group_1": ResolvedSubscriptionStatus(state: .expired, willRenew: false, offerType: nil), + "group_2": ResolvedSubscriptionStatus(state: .subscribed, willRenew: true, offerType: nil) + ] + ) + + let result = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [expired, subscribed]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) + + let entitlement = result["yearly"]?.first + #expect(entitlement?.isActive == true) + #expect(entitlement?.state == .subscribed) + #expect(entitlement?.latestProductId == "yearly") + #expect(entitlement?.expiresAt == baseDate.addingTimeInterval(86_400)) + } + + // MARK: - Billing retry + + @Test("Billing retry leaves the dates in charge") + func billingRetryDefersToDates() async { + let baseDate = Date() + let stillPaidFor = makeTransaction( + productId: "monthly", + transactionId: "txn_paid_for", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-86_400), + expirationDate: baseDate.addingTimeInterval(3600) + ) + let (raw, productIds) = fixtures(for: ["monthly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [stillPaidFor]) + + let provider = MockSubscriptionStatusProvider( + mockWillAutoRenew: true, + mockState: .inBillingRetryPeriod + ) + + let result = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [stillPaidFor]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) + + // The paid-for period has not run out yet, so access continues. + #expect(result["monthly"]?.first?.isActive == true) + #expect(result["monthly"]?.first?.state == .inBillingRetryPeriod) + #expect(subscriptions.first?.isInBillingRetryPeriod == true) + } + + @Test("Billing retry past the expiry date is inactive") + func billingRetryPastExpiryIsInactive() async { + let baseDate = Date() + let lapsed = makeTransaction( + productId: "monthly", + transactionId: "txn_lapsed", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-86_400), + expirationDate: baseDate.addingTimeInterval(-60) + ) + let (raw, productIds) = fixtures(for: ["monthly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [lapsed]) + + let provider = MockSubscriptionStatusProvider( + mockWillAutoRenew: true, + mockState: .inBillingRetryPeriod + ) + + let result = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [lapsed]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) + + #expect(result["monthly"]?.first?.isActive == false) + } + + // MARK: - Status lookups + + @Test("Each subscription group costs one status lookup") + func statusIsResolvedOncePerGroup() async { + let baseDate = Date() + let older = makeTransaction( + productId: "monthly", + transactionId: "txn_1", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-100_000), + expirationDate: baseDate.addingTimeInterval(-90_000) + ) + let newer = makeTransaction( + productId: "monthly", + transactionId: "txn_2", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-3600), + expirationDate: baseDate.addingTimeInterval(3600) + ) + + let (raw, productIds) = fixtures(for: ["monthly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [older, newer]) + let provider = MockSubscriptionStatusProvider() + + _ = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [older, newer]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) + + // One group, one lookup — against its newest transaction. + #expect(provider.resolvedTransactionIds.all == ["txn_2"]) + } + + @Test("Entitlements sharing a subscription group share its status lookup") + func statusLookupIsSharedAcrossEntitlements() async { + let baseDate = Date() + let transaction = makeTransaction( + productId: "monthly", + transactionId: "txn_1", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-3600), + expirationDate: baseDate.addingTimeInterval(3600) + ) + + let premium = makeEntitlement(id: "premium", productIds: ["monthly"]) + let proTools = makeEntitlement(id: "pro_tools", productIds: ["monthly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [transaction]) + let provider = MockSubscriptionStatusProvider() + + let result = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [transaction], "pro_tools": [transaction]], + rawEntitlementsByProductId: ["monthly": Set([premium, proTools])], + productIdsByEntitlementId: ["premium": ["monthly"], "pro_tools": ["monthly"]], + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) + + #expect(result["monthly"]?.count == 2) + #expect(result["monthly"]?.allSatisfy { $0.isActive } == true) + #expect(provider.resolvedTransactionIds.all == ["txn_1"]) + } + + @Test("An unavailable status leaves the date-based resolution alone") + func missingStatusFallsBackToDates() async { + let baseDate = Date() + let active = makeTransaction( + productId: "monthly", + transactionId: "txn_1", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-3600), + expirationDate: baseDate.addingTimeInterval(3600) + ) + + let (raw, productIds) = fixtures(for: ["monthly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [active]) + let provider = MockSubscriptionStatusProvider( + statusesByGroupId: [:], + defaultStatus: nil + ) + + let result = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [active]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) + + #expect(result["monthly"]?.first?.isActive == true) + #expect(result["monthly"]?.first?.state == nil) + } +} From cfb87386bd3e5b81f835f7af3f454401913cd18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:35:50 +0200 Subject: [PATCH 2/3] fix(entitlements): date the latest subscription by purchase, not by expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `latestSubscription` device variables describe the newest subscription on the device, so picking the source with the most time left on it could report a yearly bought last year over a monthly bought yesterday. They now follow purchase recency, and the winner is picked across every entitlement rather than left to dictionary iteration order. Three more things the per-group split left loose: - A group reporting `.subscribed` could resurrect a source whose every transaction had been refunded. It now needs an unrevoked transaction. - Forcing a lapsed source active left its expiry date in the past, so `Purchase.isActive` and `AutomaticPurchaseController` still read the entitlement as lapsed and demoted a subscriber in their grace period. The status now carries the date its access runs to — the end of the grace period, or the renewal date — and the purchase correction reads the granting product's entitlement in both directions. - `startsAt` no longer counts refunded purchases or consumables, neither of which ever unlocked the entitlement. Updates the `Entitlement` docs for the three fields whose meaning the split changed. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 + .../EntitlementProcessor.swift | 112 ++++- .../Receipt Manager/SK2ReceiptManager.swift | 76 +++- .../Products/StoreProduct/Entitlement.swift | 19 +- .../PerGroupEntitlementResolutionTests.swift | 422 ++++++++++++++++++ .../SK2ReceiptManagerTests.swift | 82 ++++ 6 files changed, 672 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81a628c7ce..9401686a34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes audiences matching users they shouldn't when you use a Purchase Controller. - Fixes an entitlement being deactivated when one of the subscriptions granting it is refunded, even though another subscription in a different subscription group is still paid for and active. Each subscription group is now evaluated on its own. - Fixes a subscription in its billing grace period being reported as inactive after its expiry date has passed. +- Fixes an entitlement in a billing grace period reporting an expiry date that has already passed. `expiresAt` is now the date the grace period ends. +- An entitlement's `latestProductId`, `expiresAt` and `willRenew` now all describe the purchase currently unlocking it, so they no longer mix details from different subscriptions. ## 4.16.3 diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift index 9924d5ba93..f66bcb10f9 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift @@ -61,6 +61,24 @@ struct ResolvedSubscriptionStatus: Sendable { let state: LatestSubscription.State? let willRenew: Bool let offerType: LatestSubscription.OfferType? + + /// The date the group's access actually runs to, when StoreKit knows it and the + /// transaction dates don't: the end of a billing grace period, or the next + /// renewal date of a subscription whose latest renewal hasn't reached + /// `Transaction.all` yet. `nil` when StoreKit doesn't report one. + let activeUntil: Date? + + init( + state: LatestSubscription.State?, + willRenew: Bool, + offerType: LatestSubscription.OfferType?, + activeUntil: Date? = nil + ) { + self.state = state + self.willRenew = willRenew + self.offerType = offerType + self.activeUntil = activeUntil + } } /// Protocol for providing subscription status information. @@ -91,10 +109,29 @@ struct StoreKitSubscriptionStatusProvider: SubscriptionStatusProvider { return ResolvedSubscriptionStatus( state: getSubscriptionState(from: status), willRenew: getWillAutoRenew(from: status), - offerType: offerType + offerType: offerType, + activeUntil: getActiveUntil(from: status) ) } + /// The date StoreKit says the subscription's access runs to. + /// + /// In a grace period that's the end of the grace period; otherwise it's the next + /// renewal date, which covers a renewal Apple has taken but `Transaction.all` + /// hasn't caught up on. + func getActiveUntil(from status: StoreKit.Product.SubscriptionInfo.Status?) -> Date? { + guard case let .verified(info) = status?.renewalInfo else { + return nil + } + if let gracePeriodExpirationDate = info.gracePeriodExpirationDate { + return gracePeriodExpirationDate + } + if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, visionOS 1.1, *) { + return info.renewalDate + } + return nil + } + func getWillAutoRenew(from status: StoreKit.Product.SubscriptionInfo.Status?) -> Bool { if case let .verified(info) = status?.renewalInfo { return info.willAutoRenew @@ -204,6 +241,11 @@ enum EntitlementProcessor { /// subscription group is the one queried for live status. let representative: any EntitlementTransaction let isLifetime: Bool + + /// Whether any transaction in this source is still unrevoked. A source whose + /// every transaction has been revoked grants nothing, whatever the + /// group-level status says. + let hasUnrevokedTransaction: Bool var isActive: Bool var expiresAt: Date? var renewedAt: Date? @@ -263,6 +305,7 @@ enum EntitlementProcessor { return GrantSource( representative: representative, isLifetime: isLifetime, + hasUnrevokedTransaction: !unrevoked.isEmpty, // A lifetime purchase never expires. Everything else grants access for // as long as an unrevoked transaction still has time left on it. isActive: isLifetime || unrevoked.contains { ($0.expirationDate ?? .distantPast) > now }, @@ -301,6 +344,18 @@ enum EntitlementProcessor { } } + /// Picks the source describing the most recently bought subscription. + /// + /// The `latestSubscription` device variables are about recency, not about which + /// source is granting access, so this deliberately differs from + /// ``representativeSource(from:)``: a yearly bought last year has more time left + /// on it than a monthly bought yesterday, but the monthly is the latest one. + static func latestSubscriptionSource(from sources: [GrantSource]) -> GrantSource? { + return sources + .filter { !$0.isLifetime } + .max { $0.latestPurchaseDate < $1.latestPurchaseDate } + } + /// Process entitlements from transactions, enriching them with metadata static func buildEntitlementsFromTransactions( from transactionsByEntitlement: [String: [any EntitlementTransaction]], @@ -333,7 +388,12 @@ enum EntitlementProcessor { // entitlement is active if any source grants it. let isActive = sources.contains { $0.isActive } let grantingSource = representativeSource(from: sources) - let startsAt = transactions.map(\.originalPurchaseDate).min() + // Only transactions that could unlock the entitlement date its start — a + // refunded purchase or a consumable never did. + let startsAt = transactions + .filter { !$0.isRevoked && $0.entitlementProductType != .consumable } + .map(\.originalPurchaseDate) + .min() // Find all product IDs for this entitlement from server config let productIds = productIdsByEntitlementId[entitlementId] ?? [] @@ -397,11 +457,12 @@ enum EntitlementProcessor { ) async -> [String: Set] { var sourcesByEntitlement: [String: [GrantSource]] = [:] var updatedSubscriptions = subscriptions + var latestSubscription: GrantSource? - // The same subscription group can back several entitlements. Cached by - // transaction ID so each group costs one status lookup rather than one per - // entitlement it grants. The value is itself optional, so an unwrapped - // `cached` here is a recorded "StoreKit had nothing to say". + // The same subscription group can back several entitlements. Cached by the + // transaction the group was resolved from, so entitlements sharing that + // transaction share one lookup. The value is itself optional, so an + // unwrapped `cached` here is a recorded "StoreKit had nothing to say". var statusCache: [String: ResolvedSubscriptionStatus?] = [:] for (entitlementId, transactions) in transactionsByEntitlement { @@ -439,7 +500,20 @@ enum EntitlementProcessor { switch status.state { case .subscribed, .inGracePeriod: - sources[index].isActive = true + // A source whose every transaction has been revoked grants nothing. + // The group status isn't clearly scoped to one Family Sharing member, + // so it must never resurrect a refunded transaction. + if sources[index].hasUnrevokedTransaction { + sources[index].isActive = true + + // Move the expiry date along with the grant. Leaving the lapsed date + // in place would make the entitlement active and already expired, + // which every downstream "good until" check reads as inactive. + if let activeUntil = status.activeUntil, + activeUntil > (sources[index].expiresAt ?? .distantPast) { + sources[index].expiresAt = activeUntil + } + } case .revoked, .expired: sources[index].isActive = false @@ -461,18 +535,24 @@ enum EntitlementProcessor { sourcesByEntitlement[entitlementId] = sources - // Report the source that actually describes the entitlement rather than - // whichever group happened to be resolved last. - if let grantingSource = representativeSource(from: sources), - !grantingSource.isLifetime { - onLatestSubscriptionUpdate?( - grantingSource.state, - grantingSource.willRenew, - grantingSource.offerType - ) + // These variables describe the latest subscription on the device, so the + // winner is the most recently bought one across every entitlement. Picking + // it up here and reporting it once keeps it out of the hands of dictionary + // iteration order. + if let candidate = latestSubscriptionSource(from: sources), + candidate.latestPurchaseDate > (latestSubscription?.latestPurchaseDate ?? .distantPast) { + latestSubscription = candidate } } + if let latestSubscription = latestSubscription { + onLatestSubscriptionUpdate?( + latestSubscription.state, + latestSubscription.willRenew, + latestSubscription.offerType + ) + } + subscriptions = updatedSubscriptions return buildEntitlements( 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 ebac7ac9b7..df4d78aaeb 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift @@ -54,6 +54,57 @@ actor SK2ReceiptManager: ReceiptManagerType { self.resolveIntroOfferEligibility = resolveIntroOfferEligibility } + /// Brings each purchase's active flag into line with the entitlements it unlocks. + /// + /// `Transaction.all` alone is not the last word on whether a product is still + /// paid for. It can hold a transaction with no revocation date or a future + /// expiry when the subscription as a whole has been refunded, and it holds + /// nothing at all to show that a lapsed subscription is in its billing grace + /// period. The entitlements have already been resolved against the live + /// subscription status, so they settle both directions: + /// + /// - Every entitlement the product unlocks is inactive: the purchase is + /// inactive, whatever its own dates say. + /// - The product is the one currently unlocking an active entitlement: the + /// purchase is active. Restricting this to the granting product stops a + /// refunded subscription looking active just because a different + /// subscription in another group still unlocks the same entitlement. + static func correctPurchases( + _ purchases: Set, + using entitlementsByProductId: [String: Set] + ) -> Set { + var correctedPurchases: Set = [] + + for purchase in purchases { + let productEntitlements = entitlementsByProductId[purchase.id] ?? [] + let allRevokedOrExpired = !productEntitlements.isEmpty && productEntitlements.allSatisfy { + !$0.isActive + } + let isGranting = productEntitlements.contains { + $0.isActive && $0.latestProductId == purchase.id + } + + let correctedIsActive: Bool + if allRevokedOrExpired { + correctedIsActive = false + } else if isGranting { + correctedIsActive = true + } else { + correctedIsActive = purchase.isActive + } + + if correctedIsActive == purchase.isActive { + correctedPurchases.insert(purchase) + } else { + correctedPurchases.insert( + Purchase(id: purchase.id, isActive: correctedIsActive, purchaseDate: purchase.purchaseDate) + ) + } + } + + return correctedPurchases + } + /// No-op for StoreKit 2. /// /// Eligibility is resolved live in `isEligibleForIntroOffer(_:)` on every call @@ -164,27 +215,10 @@ actor SK2ReceiptManager: ReceiptManagerType { capturedOfferType = offerType } - // Correct purchase active status using subscription-level state. - // A refund may revoke the subscription but individual transactions in - // Transaction.all may still appear active (no revocationDate). The - // entitlement's state from subscriptionStatus is authoritative, so if - // all entitlements for a product are revoked/expired we mark the - // purchase as inactive. - var correctedPurchases: Set = [] - for purchase in purchases { - let productEntitlements = entitlementsByProductId[purchase.id] ?? [] - let allRevokedOrExpired = !productEntitlements.isEmpty && productEntitlements.allSatisfy { - !$0.isActive - } - if allRevokedOrExpired && purchase.isActive { - correctedPurchases.insert( - Purchase(id: purchase.id, isActive: false, purchaseDate: purchase.purchaseDate) - ) - } else { - correctedPurchases.insert(purchase) - } - } - purchases = correctedPurchases + purchases = SK2ReceiptManager.correctPurchases( + purchases, + using: entitlementsByProductId + ) // Update actor-isolated properties after the async call if enableExperimentalDeviceVariables { diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift index 1cc50e3fea..1942da43b4 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift @@ -65,9 +65,14 @@ public final class Entitlement: NSObject, Codable, Sendable { /// All product identifiers that map to the entitlement. public let productIds: Set - /// The product identifer of the latest transaction to unlock this entitlement. + /// The product identifer of the product currently unlocking this entitlement. /// - /// If one or more lifetime products unlock this entitlement, the `latestProductId` will always be the product identifier of the first lifetime product. + /// An entitlement can be unlocked by more than one purchase at once — a lifetime product, or subscriptions in + /// separate subscription groups. This is the product of whichever one is granting it: a lifetime product first, + /// then the active subscription with the most time left on it. Once nothing is active, it's the product of the + /// most recent purchase. + /// + /// If more than one lifetime product unlocks this entitlement, this is the most recently bought one. /// /// This is `nil` if there aren't any transactions that unlock this entitlement or if it was manually granted from Superwall. public let latestProductId: String? @@ -90,7 +95,11 @@ public final class Entitlement: NSObject, Codable, Sendable { /// - If the entitlement belongs to a non-renewing subscription or non-consumable product. public let renewedAt: Date? - /// The expiry date of the last transaction that unlocked this entitlement. + /// The date the purchase currently unlocking this entitlement runs out. + /// + /// This is the expiry of the same purchase that ``latestProductId`` names, so it always describes the + /// subscription actually granting access rather than an unrelated one in another subscription group. For a + /// subscription in a billing grace period, it's the end of the grace period. /// /// This is `nil` if there aren't any transactions that unlock this entitlement or /// if a lifetime product unlocked this entitlement. @@ -112,7 +121,9 @@ public final class Entitlement: NSObject, Codable, Sendable { return state == .revoked } - /// Indicates whether the last subscription transaction associated with this entitlement will auto renew. + /// Indicates whether the subscription currently unlocking this entitlement will auto renew. + /// + /// This describes the same purchase that ``latestProductId`` names. /// /// This is `nil` if there aren't any transactions that unlock this entitlement or if it was manually granted from Superwall. public let willRenew: Bool? diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/PerGroupEntitlementResolutionTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/PerGroupEntitlementResolutionTests.swift index c58cd2ff45..333d9a818e 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/PerGroupEntitlementResolutionTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/PerGroupEntitlementResolutionTests.swift @@ -699,4 +699,426 @@ struct PerGroupEntitlementResolutionTests { #expect(result["monthly"]?.first?.isActive == true) #expect(result["monthly"]?.first?.state == nil) } + + // MARK: - Latest subscription device variables + + @Test("The latest subscription is the most recently bought one, not the longest one") + func latestSubscriptionFollowsPurchaseRecency() async { + let baseDate = Date() + // Bought a year ago, but it runs the furthest into the future. + let oldYearly = makeTransaction( + productId: "yearly", + transactionId: "txn_yearly", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-31_536_000), + expirationDate: baseDate.addingTimeInterval(86_400), + willRenew: false + ) + // Bought yesterday, so this is the latest subscription. + let newMonthly = makeTransaction( + productId: "monthly", + transactionId: "txn_monthly", + subscriptionGroupId: "group_2", + purchaseDate: baseDate.addingTimeInterval(-86_400), + expirationDate: baseDate.addingTimeInterval(3600) + ) + + let (raw, productIds) = fixtures(for: ["monthly", "yearly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [oldYearly, newMonthly]) + + let provider = MockSubscriptionStatusProvider( + statusesByGroupId: [ + "group_1": ResolvedSubscriptionStatus(state: .expired, willRenew: false, offerType: .promotional), + "group_2": ResolvedSubscriptionStatus(state: .subscribed, willRenew: true, offerType: .trial) + ] + ) + + var capturedState: LatestSubscription.State? + var capturedWillRenew: Bool? + var capturedOfferType: LatestSubscription.OfferType? + + _ = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [oldYearly, newMonthly]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) { state, willRenew, offerType in + capturedState = state + capturedWillRenew = willRenew + capturedOfferType = offerType + } + + #expect(capturedState == .subscribed) + #expect(capturedWillRenew == true) + #expect(capturedOfferType == .trial) + } + + @Test("The latest subscription is picked across entitlements, not by iteration order") + func latestSubscriptionIsPickedAcrossEntitlements() async { + let baseDate = Date() + let older = makeTransaction( + productId: "pro_monthly", + transactionId: "txn_pro", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-86_400), + expirationDate: baseDate.addingTimeInterval(3600) + ) + let newer = makeTransaction( + productId: "plus_monthly", + transactionId: "txn_plus", + subscriptionGroupId: "group_2", + purchaseDate: baseDate.addingTimeInterval(-60), + expirationDate: baseDate.addingTimeInterval(3600) + ) + + let pro = makeEntitlement(id: "pro", productIds: ["pro_monthly"]) + let plus = makeEntitlement(id: "plus", productIds: ["plus_monthly"]) + let raw: [String: Set] = ["pro_monthly": [pro], "plus_monthly": [plus]] + let productIds: [String: Set] = ["pro": ["pro_monthly"], "plus": ["plus_monthly"]] + + let provider = MockSubscriptionStatusProvider( + statusesByGroupId: [ + "group_1": ResolvedSubscriptionStatus(state: .inBillingRetryPeriod, willRenew: false, offerType: .code), + "group_2": ResolvedSubscriptionStatus(state: .subscribed, willRenew: true, offerType: .winback) + ] + ) + + // Run it repeatedly: the two entitlements come out of a dictionary, so a + // last-one-wins pick would only sometimes land on the wrong one. + for _ in 0..<10 { + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [older, newer]) + var capturedState: LatestSubscription.State? + var capturedOfferType: LatestSubscription.OfferType? + + _ = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["pro": [older], "plus": [newer]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) { state, _, offerType in + capturedState = state + capturedOfferType = offerType + } + + #expect(capturedState == .subscribed) + #expect(capturedOfferType == .winback) + } + } + + @Test("A lifetime purchase is never reported as the latest subscription") + func lifetimeIsNotTheLatestSubscription() async { + let baseDate = Date() + let subscription = makeTransaction( + productId: "monthly", + transactionId: "txn_monthly", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-86_400), + expirationDate: baseDate.addingTimeInterval(3600) + ) + let lifetime = makeTransaction( + productId: "lifetime", + transactionId: "txn_lifetime", + subscriptionGroupId: nil, + purchaseDate: baseDate, + productType: .nonConsumable, + willRenew: false + ) + + let (raw, productIds) = fixtures(for: ["monthly", "lifetime"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [subscription, lifetime]) + + let provider = MockSubscriptionStatusProvider( + statusesByGroupId: [ + "group_1": ResolvedSubscriptionStatus(state: .subscribed, willRenew: true, offerType: .trial) + ] + ) + + var captured: LatestSubscription.State? + var callCount = 0 + + _ = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [subscription, lifetime]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) { state, _, _ in + captured = state + callCount += 1 + } + + #expect(callCount == 1) + #expect(captured == .subscribed) + } + + // MARK: - Revoked sources + + @Test("A subscribed group cannot resurrect a fully revoked source") + func subscribedDoesNotResurrectRevokedSource() async { + let baseDate = Date() + let revoked = makeTransaction( + productId: "monthly", + transactionId: "txn_revoked", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-86_400), + expirationDate: baseDate.addingTimeInterval(86_400), + isRevoked: true + ) + let (raw, productIds) = fixtures(for: ["monthly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [revoked]) + + // A refunded Family Sharing member alongside a group that still reports as + // subscribed for its owner. + let provider = MockSubscriptionStatusProvider( + statusesByGroupId: [ + "group_1": ResolvedSubscriptionStatus(state: .subscribed, willRenew: true, offerType: nil) + ] + ) + + let result = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [revoked]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) + + #expect(result["monthly"]?.first?.isActive == false) + } + + @Test("A revoked group cannot outrank the group actually paying") + func revokedSourceDoesNotDescribeTheEntitlement() async { + let baseDate = Date() + let revoked = makeTransaction( + productId: "monthly", + transactionId: "txn_revoked", + subscriptionGroupId: "group_1", + purchaseDate: baseDate, + expirationDate: baseDate.addingTimeInterval(86_400), + isRevoked: true + ) + let paying = makeTransaction( + productId: "yearly", + transactionId: "txn_paying", + subscriptionGroupId: "group_2", + purchaseDate: baseDate.addingTimeInterval(-7200), + expirationDate: baseDate.addingTimeInterval(3600) + ) + + let (raw, productIds) = fixtures(for: ["monthly", "yearly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [revoked, paying]) + + let provider = MockSubscriptionStatusProvider( + statusesByGroupId: [ + "group_1": ResolvedSubscriptionStatus(state: .subscribed, willRenew: true, offerType: nil), + "group_2": ResolvedSubscriptionStatus(state: .subscribed, willRenew: true, offerType: nil) + ] + ) + + let result = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [revoked, paying]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) + + let entitlement = result["yearly"]?.first + #expect(entitlement?.isActive == true) + #expect(entitlement?.latestProductId == "yearly") + #expect(entitlement?.expiresAt == baseDate.addingTimeInterval(3600)) + } + + // MARK: - Expiry date of an active entitlement + + @Test("A grace period moves the expiry date to the end of the grace period") + func gracePeriodCarriesItsOwnExpiry() async { + let baseDate = Date() + let gracePeriodEnd = baseDate.addingTimeInterval(1_209_600) + let lapsed = makeTransaction( + productId: "monthly", + transactionId: "txn_lapsed", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-2_678_400), + expirationDate: baseDate.addingTimeInterval(-60) + ) + let (raw, productIds) = fixtures(for: ["monthly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [lapsed]) + + let provider = MockSubscriptionStatusProvider( + statusesByGroupId: [ + "group_1": ResolvedSubscriptionStatus( + state: .inGracePeriod, + willRenew: true, + offerType: nil, + activeUntil: gracePeriodEnd + ) + ] + ) + + let result = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [lapsed]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) + + let entitlement = result["monthly"]?.first + #expect(entitlement?.isActive == true) + // An active entitlement whose expiry has already passed reads as inactive to + // every "good until" check downstream, so the two have to move together. + #expect(entitlement?.expiresAt == gracePeriodEnd) + #expect(entitlement?.renewsAt == gracePeriodEnd) + } + + @Test("A renewal StoreKit knows about beats the stale transaction expiry") + func subscribedCarriesTheRenewalDate() async { + let baseDate = Date() + let renewalDate = baseDate.addingTimeInterval(2_678_400) + let stale = makeTransaction( + productId: "monthly", + transactionId: "txn_stale", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-2_678_400), + expirationDate: baseDate.addingTimeInterval(-60) + ) + let (raw, productIds) = fixtures(for: ["monthly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [stale]) + + let provider = MockSubscriptionStatusProvider( + statusesByGroupId: [ + "group_1": ResolvedSubscriptionStatus( + state: .subscribed, + willRenew: true, + offerType: nil, + activeUntil: renewalDate + ) + ] + ) + + let result = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [stale]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) + + #expect(result["monthly"]?.first?.isActive == true) + #expect(result["monthly"]?.first?.expiresAt == renewalDate) + } + + @Test("A live status never pulls the expiry date backwards") + func liveStatusNeverShortensTheExpiry() async { + let baseDate = Date() + let paidFor = makeTransaction( + productId: "monthly", + transactionId: "txn_paid_for", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-60), + expirationDate: baseDate.addingTimeInterval(86_400) + ) + let (raw, productIds) = fixtures(for: ["monthly"]) + var (_, subscriptions) = EntitlementProcessor.processTransactions(from: [paidFor]) + + let provider = MockSubscriptionStatusProvider( + statusesByGroupId: [ + "group_1": ResolvedSubscriptionStatus( + state: .subscribed, + willRenew: true, + offerType: nil, + activeUntil: baseDate.addingTimeInterval(3600) + ) + ] + ) + + let result = await EntitlementProcessor.buildEntitlementsWithLiveSubscriptionData( + from: ["premium": [paidFor]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds, + subscriptions: &subscriptions, + subscriptionStatusProvider: provider + ) + + #expect(result["monthly"]?.first?.expiresAt == baseDate.addingTimeInterval(86_400)) + } + + // MARK: - Scalar fields + + @Test("Renewals are dated from the group granting the entitlement") + func renewedAtComesFromTheGrantingGroup() { + let baseDate = Date() + let renewal = makeTransaction( + productId: "yearly", + transactionId: "txn_renewal", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-3600), + originalPurchaseDate: baseDate.addingTimeInterval(-31_536_000), + expirationDate: baseDate.addingTimeInterval(31_536_000) + ) + let otherGroup = makeTransaction( + productId: "monthly", + transactionId: "txn_monthly", + subscriptionGroupId: "group_2", + purchaseDate: baseDate.addingTimeInterval(-600), + originalPurchaseDate: baseDate.addingTimeInterval(-2_678_400), + expirationDate: baseDate.addingTimeInterval(3600) + ) + + let (raw, productIds) = fixtures(for: ["monthly", "yearly"]) + + let result = EntitlementProcessor.buildEntitlementsFromTransactions( + from: ["premium": [renewal, otherGroup]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds + ) + + let entitlement = result["yearly"]?.first + // The yearly has the most time left on it, so it describes the entitlement. + #expect(entitlement?.latestProductId == "yearly") + #expect(entitlement?.renewedAt == baseDate.addingTimeInterval(-3600)) + // The entitlement started with the earliest purchase that unlocked it. + #expect(entitlement?.startsAt == baseDate.addingTimeInterval(-31_536_000)) + } + + @Test("A refund and a consumable never date the start of an entitlement") + func startsAtIgnoresRefundsAndConsumables() { + let baseDate = Date() + let refunded = makeTransaction( + productId: "monthly", + transactionId: "txn_refunded", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-31_536_000), + expirationDate: baseDate.addingTimeInterval(-30_000_000), + isRevoked: true + ) + let consumable = makeTransaction( + productId: "coins", + transactionId: "txn_coins", + subscriptionGroupId: nil, + purchaseDate: baseDate.addingTimeInterval(-20_000_000), + productType: .consumable, + willRenew: false + ) + let paying = makeTransaction( + productId: "yearly", + transactionId: "txn_paying", + subscriptionGroupId: "group_2", + purchaseDate: baseDate.addingTimeInterval(-86_400), + expirationDate: baseDate.addingTimeInterval(86_400) + ) + + let (raw, productIds) = fixtures(for: ["monthly", "coins", "yearly"]) + + let result = EntitlementProcessor.buildEntitlementsFromTransactions( + from: ["premium": [refunded, consumable, paying]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds + ) + + #expect(result["yearly"]?.first?.startsAt == baseDate.addingTimeInterval(-86_400)) + } } diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/SK2ReceiptManagerTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/SK2ReceiptManagerTests.swift index bab3ff2f4e..482d4defba 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/SK2ReceiptManagerTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/SK2ReceiptManagerTests.swift @@ -26,6 +26,88 @@ struct SK2ReceiptManagerTests { ) } + // MARK: - Purchase correction + + private func makeEntitlement( + id: String = "premium", + isActive: Bool, + latestProductId: String?, + productIds: Set + ) -> Entitlement { + return Entitlement( + id: id, + type: .serviceLevel, + isActive: isActive, + productIds: productIds, + latestProductId: latestProductId, + store: .appStore + ) + } + + @Test("A purchase whose entitlements are all inactive is corrected to inactive") + func revokedEntitlementDeactivatesThePurchase() { + let purchase = Purchase(id: "monthly", isActive: true, purchaseDate: Date()) + let entitlement = makeEntitlement( + isActive: false, + latestProductId: "monthly", + productIds: ["monthly"] + ) + + let corrected = SK2ReceiptManager.correctPurchases( + [purchase], + using: ["monthly": [entitlement]] + ) + + #expect(corrected.first?.isActive == false) + } + + @Test("A purchase unlocking a grace-period entitlement is corrected to active") + func gracePeriodEntitlementReactivatesThePurchase() { + // The transaction's own expiry has passed, so the raw read says inactive. + let purchase = Purchase(id: "monthly", isActive: false, purchaseDate: Date()) + let entitlement = makeEntitlement( + isActive: true, + latestProductId: "monthly", + productIds: ["monthly"] + ) + + let corrected = SK2ReceiptManager.correctPurchases( + [purchase], + using: ["monthly": [entitlement]] + ) + + #expect(corrected.first?.isActive == true) + } + + @Test("Another group's active subscription never reactivates a refunded purchase") + func otherGroupDoesNotReactivateARefundedPurchase() { + let refunded = Purchase(id: "monthly", isActive: false, purchaseDate: Date()) + let paying = Purchase(id: "yearly", isActive: true, purchaseDate: Date()) + // One entitlement, unlocked by both products, currently granted by the yearly. + let entitlement = makeEntitlement( + isActive: true, + latestProductId: "yearly", + productIds: ["monthly", "yearly"] + ) + + let corrected = SK2ReceiptManager.correctPurchases( + [refunded, paying], + using: ["monthly": [entitlement], "yearly": [entitlement]] + ) + + #expect(corrected.first { $0.id == "monthly" }?.isActive == false) + #expect(corrected.first { $0.id == "yearly" }?.isActive == true) + } + + @Test("A purchase that maps to no entitlement keeps its own active flag") + func unmappedPurchaseIsLeftAlone() { + let purchase = Purchase(id: "monthly", isActive: true, purchaseDate: Date()) + + let corrected = SK2ReceiptManager.correctPurchases([purchase], using: [:]) + + #expect(corrected.first?.isActive == true) + } + @Test("isEligibleForIntroOffer re-queries StoreKit on every call and is never cached") func eligibilityIsNotCached() async { guard #available(iOS 15.0, *) else { From 4be51bd36747ef17ecd009d76a7b7ca9d268ce09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:41:36 +0200 Subject: [PATCH 3/3] fix(entitlements): date a renewal from any group, not just the granting one `renewedAt` isn't a property of the current subscription period, so taking it from the source describing the entitlement dropped a renewal that happened in another group. It's now the latest renewal across every source. Co-Authored-By: Claude Opus 5 --- .../EntitlementProcessor.swift | 8 +++- .../PerGroupEntitlementResolutionTests.swift | 43 +++++++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift index f66bcb10f9..6e6e207344 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift @@ -388,6 +388,12 @@ enum EntitlementProcessor { // entitlement is active if any source grants it. let isActive = sources.contains { $0.isActive } let grantingSource = representativeSource(from: sources) + + // Unlike `state` and `willRenew`, this isn't a property of the current + // subscription period, so it takes the latest renewal from any source. A + // group that renewed last week has renewed whether or not it's the one + // describing the entitlement today. + let renewedAt = sources.compactMap(\.renewedAt).max() // Only transactions that could unlock the entitlement date its start — a // refunded purchase or a consumable never did. let startsAt = transactions @@ -414,7 +420,7 @@ enum EntitlementProcessor { latestProductId: grantingSource?.latestProductId, store: .appStore, startsAt: startsAt, - renewedAt: grantingSource?.renewedAt, + renewedAt: renewedAt, expiresAt: grantingSource?.expiresAt, isLifetime: grantingSource?.isLifetime ?? false, willRenew: grantingSource?.willRenew ?? false, diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/PerGroupEntitlementResolutionTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/PerGroupEntitlementResolutionTests.swift index 333d9a818e..894f585d17 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/PerGroupEntitlementResolutionTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/PerGroupEntitlementResolutionTests.swift @@ -1048,8 +1048,8 @@ struct PerGroupEntitlementResolutionTests { // MARK: - Scalar fields - @Test("Renewals are dated from the group granting the entitlement") - func renewedAtComesFromTheGrantingGroup() { + @Test("The entitlement is dated by its most recent renewal in any group") + func renewedAtIsTheLatestRenewalAcrossGroups() { let baseDate = Date() let renewal = makeTransaction( productId: "yearly", @@ -1079,11 +1079,48 @@ struct PerGroupEntitlementResolutionTests { let entitlement = result["yearly"]?.first // The yearly has the most time left on it, so it describes the entitlement. #expect(entitlement?.latestProductId == "yearly") - #expect(entitlement?.renewedAt == baseDate.addingTimeInterval(-3600)) + // Both groups renewed; the monthly did so more recently. + #expect(entitlement?.renewedAt == baseDate.addingTimeInterval(-600)) // The entitlement started with the earliest purchase that unlocked it. #expect(entitlement?.startsAt == baseDate.addingTimeInterval(-31_536_000)) } + @Test("A renewal in one group survives another group describing the entitlement") + func renewedAtSurvivesAcrossGroups() { + let baseDate = Date() + // First period, no renewal yet, but it runs the furthest into the future so + // it describes the entitlement. + let firstPeriod = makeTransaction( + productId: "yearly", + transactionId: "txn_yearly", + subscriptionGroupId: "group_1", + purchaseDate: baseDate.addingTimeInterval(-600), + expirationDate: baseDate.addingTimeInterval(31_536_000) + ) + // Renewed last week, in a group of its own. + let renewed = makeTransaction( + productId: "monthly", + transactionId: "txn_monthly", + subscriptionGroupId: "group_2", + purchaseDate: baseDate.addingTimeInterval(-604_800), + originalPurchaseDate: baseDate.addingTimeInterval(-2_678_400), + expirationDate: baseDate.addingTimeInterval(3600) + ) + + let (raw, productIds) = fixtures(for: ["monthly", "yearly"]) + + let result = EntitlementProcessor.buildEntitlementsFromTransactions( + from: ["premium": [firstPeriod, renewed]], + rawEntitlementsByProductId: raw, + productIdsByEntitlementId: productIds + ) + + let entitlement = result["yearly"]?.first + #expect(entitlement?.latestProductId == "yearly") + // The entitlement has renewed, even though the group describing it hasn't. + #expect(entitlement?.renewedAt == baseDate.addingTimeInterval(-604_800)) + } + @Test("A refund and a consumable never date the start of an entitlement") func startsAtIgnoresRefundsAndConsumables() { let baseDate = Date()