Skip to content

Commit 0f2cae7

Browse files
authored
Merge pull request #506 from superwall/fix/subscription-status-anti-downgrade
Keep subscribers active through empty StoreKit reads and empty web polls
2 parents bcaee89 + 82ff1ee commit 0f2cae7

10 files changed

Lines changed: 797 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub.
44

5+
## 4.16.4
6+
7+
### Fixes
8+
9+
- Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately.
10+
- 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.
11+
512
## 4.16.3
613

714
### Fixes

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ When bumping the version, update all three files:
7272
3. `CHANGELOG.md` (add new version entry at top)
7373

7474
- Follows semantic versioning
75+
- **Never use an `## Unreleased` heading in `CHANGELOG.md`.** Unreleased changes always live under the next concrete version number (e.g. `## 4.16.4`).
76+
- To pick that number, compare the version on `develop` with the version on `master`:
77+
- If develop's version is **above** master's, a release is already staged — add your entries to that existing top section. Do not bump again.
78+
- If develop's version **equals** master's, start the next release: add a new version section and bump all three files together (patch/minor/major per the change).
7579

7680
### Testing
7781

Sources/SuperwallKit/Misc/Constants.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,5 @@ let sdkVersion = """
1818
*/
1919

2020
let sdkVersion = """
21-
4.16.3
21+
4.16.4
2222
"""

Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK1ReceiptManager.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ final class SK1ReceiptManager: ReceiptManagerType {
7272

7373
// Build map of active product IDs for quick lookup
7474
let activeProductIds = Set(purchases.filter { $0.isActive }.map { $0.id })
75+
let purchasedProductIds = Set(purchases.map { $0.id })
7576

7677
// Process all entitlements from config, enhancing them with active status
7778
// For SK1, we collect all product IDs per entitlement, then mark as active if ANY product is active
@@ -92,12 +93,22 @@ final class SK1ReceiptManager: ReceiptManagerType {
9293
// Entitlement is active if ANY of its products is active
9394
let isActive = productIds.contains { activeProductIds.contains($0) }
9495

96+
// A receipt transaction for any of the entitlement's products makes
97+
// it an App Store entitlement, matching the StoreKit 2 path. Without
98+
// one the store stays nil, per the `Entitlement.store` contract. The
99+
// anti-downgrade guard relies on active device-derived entitlements
100+
// carrying `.appStore`: a device read may only refute those, and a
101+
// nil store marks a grant from outside the App Store (web, manual).
102+
let store: EntitlementStore? =
103+
productIds.contains { purchasedProductIds.contains($0) } ? .appStore : nil
104+
95105
entitlements.append(
96106
Entitlement(
97107
id: entitlementId,
98108
type: entitlementTypes[entitlementId] ?? .serviceLevel,
99109
isActive: isActive,
100-
productIds: productIds
110+
productIds: productIds,
111+
store: store
101112
)
102113
)
103114
}

Sources/SuperwallKit/StoreKit/Purchase Controller/AutomaticPurchaseController.swift

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ final class AutomaticPurchaseController {
2020
self.entitlementsInfo = entitlementsInfo
2121
}
2222

23-
func syncSubscriptionStatus(withPurchases purchases: Set<Purchase>) async {
23+
func syncSubscriptionStatus(
24+
withPurchases purchases: Set<Purchase>,
25+
superwall: Superwall? = nil
26+
) async {
2427
let activePurchases = purchases.filter { $0.isActive }
2528
var entitlements: Set<Entitlement> = []
2629

@@ -29,11 +32,62 @@ final class AutomaticPurchaseController {
2932
entitlements = entitlements.union(purchaseEntitlements)
3033
}
3134

35+
let activeProductIds = Set(activePurchases.map { $0.id })
36+
3237
await MainActor.run { [entitlements] in
38+
let superwall = superwall ?? Superwall.shared
3339
if entitlements.isEmpty {
34-
Superwall.shared.internallySetSubscriptionStatus(to: .inactive)
40+
// A device read with no entitlements can mean different things,
41+
// and only one of them may demote a subscriber:
42+
//
43+
// - Purchases exist but none is active: an authoritative answer.
44+
// Refunded and expired transactions stay in the set as inactive
45+
// (SK2 reads `Transaction.all`; the SK1 receipt keeps cancelled
46+
// purchases), so this downgrades immediately.
47+
// - The purchases set is completely empty: a non-answer. StoreKit
48+
// returns nothing at cold launch before it hydrates, the SK1
49+
// receipt can be missing, and web/Stripe subscribers have no App
50+
// Store purchases at all.
51+
// - A purchase is still active but maps to no entitlement: a
52+
// mapping failure (the config no longer knows the product), not
53+
// an authoritative answer for the entitlement it unlocks.
54+
//
55+
// On a non-answer, keep an `.active` status while one of its
56+
// entitlements is within its expiry date. A device read also has no
57+
// authority over entitlements not granted by the App Store, so those
58+
// hold the status even when unrelated inactive purchases exist. A
59+
// nil store means no App Store transaction unlocks the entitlement
60+
// (web or manual grant — both receipt managers stamp `.appStore` on
61+
// entitlements a receipt transaction unlocks, so active
62+
// device-derived entitlements always carry it), so nil is protected
63+
// too. Entitlements with no expiry date never hold the status, so a
64+
// revoked lifetime purchase can still deactivate here.
65+
if case .active(let currentEntitlements) = superwall.subscriptionStatus {
66+
let holdsStatus = currentEntitlements.contains { entitlement in
67+
guard entitlement.isActive,
68+
(entitlement.expiresAt ?? .distantPast) > Date() else {
69+
return false
70+
}
71+
if purchases.isEmpty || entitlement.store != .appStore {
72+
return true
73+
}
74+
// A still-active purchase that unlocks this entitlement means
75+
// the empty entitlement set is a mapping failure. With the
76+
// mapping missing, SK2's subscription-level correction of
77+
// `Purchase.isActive` is disabled too, so this is the raw
78+
// transaction-level value and can miss a revocation that sets
79+
// no `revocationDate`. The hold is still bounded by the expiry
80+
// gate above, which beats locking out a paying subscriber over
81+
// a lost product mapping.
82+
return entitlement.productIds.contains { activeProductIds.contains($0) }
83+
}
84+
if holdsStatus {
85+
return
86+
}
87+
}
88+
superwall.internallySetSubscriptionStatus(to: .inactive, superwall: superwall)
3589
} else {
36-
Superwall.shared.internallySetSubscriptionStatus(to: .active(entitlements))
90+
superwall.internallySetSubscriptionStatus(to: .active(entitlements), superwall: superwall)
3791
}
3892
}
3993
}

Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,6 +920,31 @@ actor WebEntitlementRedeemer {
920920
deviceId: factory.makeDeviceId()
921921
)
922922

923+
// A response with zero entitlements must not replace cached web
924+
// entitlements that are still within their expiry date. The server
925+
// reports a revocation by returning the entitlement as inactive —
926+
// and it enumerates every config-mapped entitlement even for users
927+
// with no purchases — so a fully empty array is a backend or config
928+
// artifact (alias mismatch, failed upstream lookup), not a
929+
// revocation. Real revocations arrive non-empty and apply
930+
// immediately through the save below. If an empty response replaced
931+
// the cache, the next cold launch would read the user as inactive
932+
// until a network poll recovered them. Entitlements with no expiry
933+
// date are not protected by this guard. We skip saving the fetch
934+
// date so the next poll retries without waiting out
935+
// `entitlementsMaxAge`.
936+
let hasUnexpiredWebEntitlements = existingWebEntitlements.contains {
937+
$0.isActive && ($0.expiresAt ?? .distantPast) > Date()
938+
}
939+
if response.customerInfo.entitlements.isEmpty && hasUnexpiredWebEntitlements {
940+
Logger.debug(
941+
logLevel: .warn,
942+
scope: .webEntitlements,
943+
message: "Ignoring empty web entitlements response because unexpired web entitlements are cached."
944+
)
945+
return
946+
}
947+
923948
// Update the latest redeem response with the entitlements and customer info from the response.
924949
if var latestRedeemResponse = storage.get(LatestRedeemResponse.self) {
925950
latestRedeemResponse.customerInfo = response.customerInfo

SuperwallKit.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Pod::Spec.new do |s|
22

33
s.name = "SuperwallKit"
4-
s.version = "4.16.3"
4+
s.version = "4.16.4"
55
s.summary = "Superwall: In-App Paywalls Made Easy"
66
s.description = "Paywall infrastructure for mobile apps :) we make things like editing your paywall and running price tests as easy as clicking a few buttons. superwall.com"
77

SuperwallKit.xcodeproj/project.pbxproj

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
archiveVersion = 1;
44
classes = {
55
};
6-
objectVersion = 54;
6+
objectVersion = 77;
77
objects = {
88

99
/* Begin PBXBuildFile section */
@@ -474,6 +474,7 @@
474474
D506526569FAA54E3220A02A /* PurchaseSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F67A5C0CA15AF645709A2545 /* PurchaseSource.swift */; };
475475
D56F32CB484F74EC7E49E581 /* PaywallViewControllerCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BAEECE2DBFEB8817E6C36DA /* PaywallViewControllerCache.swift */; };
476476
D5A1334579BE0B0A207B0BF7 /* TestModeModalViewController+TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0D4F9888B5827992153F5F /* TestModeModalViewController+TableView.swift */; };
477+
D5C9A71CFB166225C082CAFB /* AutomaticPurchaseControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F3523491EC638DBBBD2133 /* AutomaticPurchaseControllerTests.swift */; };
477478
D66461863D54A56BE9C29310 /* Publisher+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF5A8FFFC23826AD113D525A /* Publisher+Async.swift */; };
478479
D6CA719D79BAA6369D1C01C3 /* AudienceLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71CF4BE5D2A6CEA9F8993C81 /* AudienceLogic.swift */; };
479480
D77DB3187C62B91E3D55DF80 /* ArchiveRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4300EBF7463A42D2FB89371 /* ArchiveRequest.swift */; };
@@ -1167,6 +1168,7 @@
11671168
F13CC9902419E7D68B47C184 /* PopupTransitionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupTransitionDelegate.swift; sourceTree = "<group>"; };
11681169
F16AFE9C93A441CFB6A95F10 /* String+CamelCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+CamelCase.swift"; sourceTree = "<group>"; };
11691170
F2A2A54314BAEAF65B46D322 /* NetworkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkTests.swift; sourceTree = "<group>"; };
1171+
F2F3523491EC638DBBBD2133 /* AutomaticPurchaseControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticPurchaseControllerTests.swift; sourceTree = "<group>"; };
11701172
F338AF233A9EF2A20B1AC5A5 /* MockPurchaseController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPurchaseController.swift; sourceTree = "<group>"; };
11711173
F34468E3988E779132CE101A /* BundleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleHelper.swift; sourceTree = "<group>"; };
11721174
F36CB341B28F250F5252A8DF /* Transaction+LatestSince.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Transaction+LatestSince.swift"; sourceTree = "<group>"; };
@@ -1298,6 +1300,7 @@
12981300
054FCADFEF560A1A736DEFE4 /* StoreKitManagerTests.swift */,
12991301
C0D3F44546D33F8AA71A79B9 /* Mocks */,
13001302
2C05828E18C52F510F7CDFA2 /* Products */,
1303+
42F8F9824C94A7946BD46450 /* Purchase Controller */,
13011304
357496424A62506BB5B92AB9 /* Transactions */,
13021305
);
13031306
path = StoreKit;
@@ -1799,6 +1802,14 @@
17991802
path = Certificates;
18001803
sourceTree = "<group>";
18011804
};
1805+
42F8F9824C94A7946BD46450 /* Purchase Controller */ = {
1806+
isa = PBXGroup;
1807+
children = (
1808+
F2F3523491EC638DBBBD2133 /* AutomaticPurchaseControllerTests.swift */,
1809+
);
1810+
path = "Purchase Controller";
1811+
sourceTree = "<group>";
1812+
};
18021813
43AE802CA13E36803E2FF13E /* Popup Transition */ = {
18031814
isa = PBXGroup;
18041815
children = (
@@ -3148,6 +3159,8 @@
31483159
8F41784FB73AC60BED81E582 /* PBXTargetDependency */,
31493160
);
31503161
name = SuperwallKitTests;
3162+
packageProductDependencies = (
3163+
);
31513164
productName = SuperwallKitTests;
31523165
productReference = 92001AC11F099F7B03AF338A /* SuperwallKitTests.xctest */;
31533166
productType = "com.apple.product-type.bundle.unit-test";
@@ -3160,8 +3173,6 @@
31603173
attributes = {
31613174
BuildIndependentTargetsInParallel = YES;
31623175
LastUpgradeCheck = 1430;
3163-
TargetAttributes = {
3164-
};
31653176
};
31663177
buildConfigurationList = B7BB212B66F694F1FDA2FA4F /* Build configuration list for PBXProject "SuperwallKit" */;
31673178
compatibilityVersion = "Xcode 14.0";
@@ -3212,9 +3223,11 @@
32123223
zh_Hant,
32133224
);
32143225
mainGroup = 5CE8CEF97A892FFF3D0D8F06;
3226+
minimizedProjectReferenceProxies = 1;
32153227
packageReferences = (
32163228
89F17188BC665EFC6FE5CEFA /* XCRemoteSwiftPackageReference "superscript-ios-next" */,
32173229
);
3230+
preferredProjectObjectVersion = 77;
32183231
projectDirPath = "";
32193232
projectRoot = "";
32203233
targets = (
@@ -3252,6 +3265,7 @@
32523265
59685CE55D34FA6A96A8F890 /* AssignmentLogicTests.swift in Sources */,
32533266
BC8A62869C7BACE6D0867195 /* AssignmentTests.swift in Sources */,
32543267
3CD2C23BAC2EA11174237785 /* AttributionTests.swift in Sources */,
3268+
D5C9A71CFB166225C082CAFB /* AutomaticPurchaseControllerTests.swift in Sources */,
32553269
B0DC8290B081B74CC65E9305 /* CELEvaluatorTests.swift in Sources */,
32563270
E984458E465D5A834CC52302 /* CacheMock.swift in Sources */,
32573271
CBFC0D2DCA996A5FF7E5174B /* CacheTests.swift in Sources */,

0 commit comments

Comments
 (0)