Просмотр исходного кода

Fix premium subscribers seeing upgrade prompts after launch.

Preserve cached entitlements while StoreKit reconciles on startup so returning subscribers keep premium access and the AI tools reflect the correct subscription state.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 недель назад
Родитель
Сommit
b9f13f5ade

+ 54 - 6
App AI for Reddit/Managers/SubscriptionManager.swift

@@ -73,6 +73,7 @@ final class SubscriptionManager: ObservableObject {
     private var initialEntitlementTask: Task<Void, Never>?
     private var initialEntitlementTask: Task<Void, Never>?
     private var entitlementRefreshTask: Task<Void, Never>?
     private var entitlementRefreshTask: Task<Void, Never>?
     private var hasStarted = false
     private var hasStarted = false
+    private var launchedWithCachedPremium = false
     private var cancellables = Set<AnyCancellable>()
     private var cancellables = Set<AnyCancellable>()
     private var lastBackgroundEntitlementRefresh: Date?
     private var lastBackgroundEntitlementRefresh: Date?
 
 
@@ -84,6 +85,7 @@ final class SubscriptionManager: ObservableObject {
 
 
         // Optimistic restore so returning subscribers aren't paywalled while StoreKit resolves.
         // Optimistic restore so returning subscribers aren't paywalled while StoreKit resolves.
         if UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) {
         if UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) {
+            launchedWithCachedPremium = true
             hasPremiumAccess = true
             hasPremiumAccess = true
             if let planRaw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
             if let planRaw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
                let plan = PaywallPlan(rawValue: planRaw) {
                let plan = PaywallPlan(rawValue: planRaw) {
@@ -101,6 +103,22 @@ final class SubscriptionManager: ObservableObject {
 
 
         initialEntitlementTask = Task {
         initialEntitlementTask = Task {
             await refreshPremiumAccess()
             await refreshPremiumAccess()
+
+            let shouldReconcileWithAppStore = launchedWithCachedPremium
+                || (!hasPremiumAccess && hasEverPurchasedPremium)
+
+            if shouldReconcileWithAppStore {
+                do {
+                    try await syncAppStoreWithTimeout(seconds: 15)
+                } catch {
+                    #if DEBUG
+                    print("[SubscriptionManager] App Store sync failed during premium reconciliation")
+                    #endif
+                }
+                launchedWithCachedPremium = false
+                await refreshPremiumAccess()
+            }
+
             await loadProducts(presentLoadingUI: false)
             await loadProducts(presentLoadingUI: false)
         }
         }
     }
     }
@@ -366,17 +384,31 @@ final class SubscriptionManager: ObservableObject {
             }
             }
         }
         }
 
 
+        if resolvedPlan == nil {
+            resolvedPlan = await activePlanFromLatestTransactions()
+        }
+
         let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
         let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
-        activePremiumPlan = finalPlan
-        hasPremiumAccess = finalPlan != nil
 
 
-        if hasPremiumAccess {
+        if let finalPlan {
+            activePremiumPlan = finalPlan
+            hasPremiumAccess = true
+            launchedWithCachedPremium = false
             markAsHavingPurchasedPremium()
             markAsHavingPurchasedPremium()
+        } else if launchedWithCachedPremium, activePremiumPlan != nil {
+            // StoreKit can lag behind on launch; keep the cached plan until reconciliation finishes.
+            hasPremiumAccess = true
+        } else {
+            activePremiumPlan = nil
+            hasPremiumAccess = false
         }
         }
 
 
-        hasResolvedPremiumStatus = true
-        persistPremiumCache()
-        await syncPurchaseHistory()
+        let resolved = !launchedWithCachedPremium
+        if resolved {
+            hasResolvedPremiumStatus = true
+            persistPremiumCache()
+            await syncPurchaseHistory()
+        }
         await refreshTrialOffers()
         await refreshTrialOffers()
     }
     }
 
 
@@ -414,6 +446,7 @@ final class SubscriptionManager: ObservableObject {
     }
     }
 
 
     private func applyTrustedPlan(_ plan: PaywallPlan) {
     private func applyTrustedPlan(_ plan: PaywallPlan) {
+        launchedWithCachedPremium = false
         activePremiumPlan = preferredPlan(activePremiumPlan, plan)
         activePremiumPlan = preferredPlan(activePremiumPlan, plan)
         hasPremiumAccess = true
         hasPremiumAccess = true
         hasResolvedPremiumStatus = true
         hasResolvedPremiumStatus = true
@@ -422,6 +455,7 @@ final class SubscriptionManager: ObservableObject {
     }
     }
 
 
     private func revokePremiumAccess() {
     private func revokePremiumAccess() {
+        launchedWithCachedPremium = false
         activePremiumPlan = nil
         activePremiumPlan = nil
         hasPremiumAccess = false
         hasPremiumAccess = false
         hasResolvedPremiumStatus = true
         hasResolvedPremiumStatus = true
@@ -448,6 +482,7 @@ final class SubscriptionManager: ObservableObject {
 
 
     private func applyPremiumAccess(from transaction: StoreKit.Transaction) {
     private func applyPremiumAccess(from transaction: StoreKit.Transaction) {
         guard let plan = planGrantingPremium(from: transaction) else { return }
         guard let plan = planGrantingPremium(from: transaction) else { return }
+        launchedWithCachedPremium = false
         activePremiumPlan = preferredPlan(activePremiumPlan, plan)
         activePremiumPlan = preferredPlan(activePremiumPlan, plan)
         hasPremiumAccess = true
         hasPremiumAccess = true
         hasResolvedPremiumStatus = true
         hasResolvedPremiumStatus = true
@@ -561,6 +596,19 @@ final class SubscriptionManager: ObservableObject {
         return candidate
         return candidate
     }
     }
 
 
+    private func activePlanFromLatestTransactions() async -> PaywallPlan? {
+        var resolvedPlan: PaywallPlan?
+
+        for productID in SubscriptionProductID.all {
+            guard let result = await Transaction.latest(for: productID) else { continue }
+            guard case .verified(let transaction) = result else { continue }
+            guard let plan = planGrantingPremium(from: transaction) else { continue }
+            resolvedPlan = preferredPlan(resolvedPlan, plan)
+        }
+
+        return resolvedPlan
+    }
+
     private func activeLifetimePlan() async -> PaywallPlan? {
     private func activeLifetimePlan() async -> PaywallPlan? {
         guard let result = await Transaction.latest(for: SubscriptionProductID.lifetime) else {
         guard let result = await Transaction.latest(for: SubscriptionProductID.lifetime) else {
             return nil
             return nil

+ 1 - 1
App AI for Reddit/Views/FrontPageView.swift

@@ -35,7 +35,7 @@ struct FrontPageView: View {
             .fullScreenLayoutWhenNeeded()
             .fullScreenLayoutWhenNeeded()
             .environment(\.requirePremiumAccess, {
             .environment(\.requirePremiumAccess, {
                 if subscriptions.isResolvingEntitlements {
                 if subscriptions.isResolvingEntitlements {
-                    return false
+                    return subscriptions.hasPremiumAccess
                 }
                 }
                 if subscriptions.hasPremiumAccess { return true }
                 if subscriptions.hasPremiumAccess { return true }
                 viewModel.showPaywall()
                 viewModel.showPaywall()

+ 22 - 1
App AI for Reddit/Views/RootView.swift

@@ -1,6 +1,7 @@
 import SwiftUI
 import SwiftUI
 
 
 struct RootView: View {
 struct RootView: View {
+    @EnvironmentObject private var subscriptions: SubscriptionManager
     let session: AppSessionState
     let session: AppSessionState
     @Bindable var splashViewModel: SplashViewModel
     @Bindable var splashViewModel: SplashViewModel
 
 
@@ -16,7 +17,27 @@ struct RootView: View {
         }
         }
         .animation(.easeInOut(duration: 0.45), value: splashViewModel.isVisible)
         .animation(.easeInOut(duration: 0.45), value: splashViewModel.isVisible)
         .task {
         .task {
-            await splashViewModel.runIfNeeded()
+            async let splash: Void = splashViewModel.runIfNeeded()
+            async let entitlements: Void = subscriptions.ensureEntitlementsResolved()
+            _ = await (splash, entitlements)
+            syncToolSubscriptionState()
         }
         }
     }
     }
+
+    private func syncToolSubscriptionState() {
+        let hasPremium = subscriptions.hasPremiumAccess
+        let hasEverPurchased = subscriptions.hasEverPurchasedPremium
+        session.postGenerator.setSubscriptionState(
+            hasPremiumAccess: hasPremium,
+            hasEverPurchasedPremium: hasEverPurchased
+        )
+        session.titleOptimizer.setSubscriptionState(
+            hasPremiumAccess: hasPremium,
+            hasEverPurchasedPremium: hasEverPurchased
+        )
+        session.commentWriter.setSubscriptionState(
+            hasPremiumAccess: hasPremium,
+            hasEverPurchasedPremium: hasEverPurchased
+        )
+    }
 }
 }