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

Filter by extension

Filter by extension

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

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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<Purchase>,
using entitlementsByProductId: [String: Set<Entitlement>]
) -> Set<Purchase> {
var correctedPurchases: Set<Purchase> = []

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
}
Comment on lines +83 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The restriction to latestProductId doesn't hold the guarantee the doc above claims, because latestProductId can itself name a refunded product. grantSources picks representative as bucket.max(by: purchaseDate) over the whole bucket (EntitlementProcessor.swift:299) while isActive/expiresAt come from unrevoked only, so a group whose newest transaction was refunded but whose older sibling is still unexpired stays active with the refunded product as its latestProductId. This branch then flips that refunded Purchase to active, and ReceiptManager.isSubscribed(to: refundedProductId) starts returning true.

Technical details
# A refunded transaction can be the representative of an active grant source

## Affected sites
- `Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift:299``bucket.max(by: { $0.purchaseDate < $1.purchaseDate })` is not filtered for
  `isRevoked`, unlike `unrevoked` on the next line which feeds `isActive`,
  `expiresAt` and `renewedAt`.
- `EntitlementProcessor.swift:256` / `:317``latestProductId` and `willRenew` are
  read off that representative, so both can describe the refunded purchase while the
  entitlement is active.
- `Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift:83-85``isGranting` matches on `latestProductId`, so the refunded purchase is corrected
  to `isActive = true`.
- `Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/ReceiptManager.swift:262-276``isSubscribed(to:)` sorts newest-first and reads `isActive`, and
  `getActiveProductIds()` unions active ids, so both then include the refunded product.
- `Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift:68` — the doc
  rewritten in this commit promises "the product currently unlocking this entitlement".

## Reachability
The bucket is one subscription group. The case needs a transaction that is newer than
a still-unexpired sibling in the same group and revoked — a family-shared subscription
alongside the user's own in that group, or a mid-cycle plan change that is later
refunded. This is the same coexistence the new `hasUnrevokedTransaction` comment
already assumes is possible.

## Required outcome
- While a grant source has any unrevoked transaction, the scalars it reports
  (`latestProductId`, `willRenew`, `latestPurchaseDate`) must describe an unrevoked
  transaction, and `correctPurchases` must never mark a refunded purchase active.

## Suggested approach (optional)
- In `grantSources`, pick the representative from `unrevoked` when it is non-empty and
  fall back to the whole bucket only when everything is revoked (which is exactly the
  `hasUnrevokedTransaction == false` case, where the source grants nothing anyway).
  A test pairing a revoked newest transaction with an unexpired unrevoked sibling in
  the same `subscriptionGroupId` would pin it.


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
Expand Down Expand Up @@ -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<Purchase> = []
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,14 @@ public final class Entitlement: NSObject, Codable, Sendable {
/// All product identifiers that map to the entitlement.
public let productIds: Set<String>

/// 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?
Expand All @@ -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.
Expand All @@ -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?
Expand Down
4 changes: 4 additions & 0 deletions SuperwallKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1004,6 +1005,7 @@
ABC1253C6D5DD8D967BE05D1 /* LocalizationGrouping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationGrouping.swift; sourceTree = "<group>"; };
ABD045A5C4A47B1CA9365285 /* MicrophonePermissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrophonePermissionTests.swift; sourceTree = "<group>"; };
AC11F0D5B6B8A0F5EC5D200B /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = "<group>"; };
AC43181BC61F116C4FD06657 /* PerGroupEntitlementResolutionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGroupEntitlementResolutionTests.swift; sourceTree = "<group>"; };
AC74F16DC5A17489E97061EA /* PushTransitionLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushTransitionLogic.swift; sourceTree = "<group>"; };
AD23BBF9EA198ED8798A8F62 /* SystemInfo+NotificationName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemInfo+NotificationName.swift"; sourceTree = "<group>"; };
AE32816F1CA1637897AC87A2 /* UNUserNotificationCenter+SuperwallNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UNUserNotificationCenter+SuperwallNotifications.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1303,6 +1305,7 @@
children = (
9E3DAD767490972EA30257F9 /* EntitlementProcessorTests.swift */,
39B81D88316F06C0C2757F10 /* MockReceiptData.swift */,
AC43181BC61F116C4FD06657 /* PerGroupEntitlementResolutionTests.swift */,
03471273DF4C875227102BE2 /* ReceiptManagerTests.swift */,
A08CC3D275A02927073952EB /* ReceiptManagerTrialEligibilityTests.swift */,
87B1E659458AE78C3908562B /* SK2ReceiptManagerTests.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Loading
Loading