diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1880528a..9401686a34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ 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. +- 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 73a3cc683c..6e6e207344 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift @@ -56,21 +56,80 @@ 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? + + /// 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. +/// +/// 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, + 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 { @@ -167,121 +226,211 @@ enum EntitlementProcessor { return (nonSubscriptions, subscriptions) } - /// 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 - } - } - } + // 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 + + /// 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? + var willRenew: Bool + var state: LatestSubscription.State? + var offerType: LatestSubscription.OfferType? + + var latestProductId: String { representative.productId } + var latestPurchaseDate: Date { representative.purchaseDate } + } - // 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 - } - } + /// 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]] = [:] - // Track renewal date - if transaction.entitlementProductType == .autoRenewable, - !transaction.isRevoked { - if transaction.originalPurchaseDate < transaction.purchaseDate, - renewedAt == nil || renewedAt ?? Date() < transaction.purchaseDate { - renewedAt = transaction.purchaseDate - } - } + for transaction in transactions { + let key: String - // 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 - } + 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 latestProductId == nil { - latestProductId = mostRecentRenewable?.productId + if buckets[key] == nil { + bucketKeys.append(key) } + buckets[key, default: []].append(transaction) + } - // Store the most recent renewable for this entitlement (only if it exists) - if let mostRecentRenewable = mostRecentRenewable { - mostRecentRenewableByEntitlement[entitlementId] = mostRecentRenewable + 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, + 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 }, + 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) + } + } + + /// 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]], + rawEntitlementsByProductId: [String: Set], + productIdsByEntitlementId: [String: Set] + ) -> [String: Set] { + let sourcesByEntitlement = transactionsByEntitlement.mapValues { grantSources(for: $0) } + + return buildEntitlements( + from: transactionsByEntitlement, + sourcesByEntitlement: sourcesByEntitlement, + rawEntitlementsByProductId: rawEntitlementsByProductId, + productIdsByEntitlementId: productIdsByEntitlementId + ) + } + + /// 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] = [:] + + for (entitlementId, transactions) in transactionsByEntitlement { + let sources = sourcesByEntitlement[entitlementId] ?? [] + + // 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) + + // 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 + .filter { !$0.isRevoked && $0.entitlementProductType != .consumable } + .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: 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 +461,112 @@ 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 + var latestSubscription: GrantSource? - // 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 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?] = [:] - // 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: + // 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 + 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 - } + sourcesByEntitlement[entitlementId] = sources - 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 - } + // 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 } } - return finalEntitlementsByProductId + if let latestSubscription = latestSubscription { + onLatestSubscriptionUpdate?( + latestSubscription.state, + latestSubscription.willRenew, + latestSubscription.offerType + ) + } + + subscriptions = updatedSubscriptions + + return buildEntitlements( + from: transactionsByEntitlement, + sourcesByEntitlement: sourcesByEntitlement, + rawEntitlementsByProductId: rawEntitlementsByProductId, + productIdsByEntitlementId: productIdsByEntitlementId + ) } } 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/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..894f585d17 --- /dev/null +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/PerGroupEntitlementResolutionTests.swift @@ -0,0 +1,1161 @@ +// +// 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) + } + + // 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("The entitlement is dated by its most recent renewal in any group") + func renewedAtIsTheLatestRenewalAcrossGroups() { + 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") + // 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() + 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 {