فهرست منبع

Gate paywall trial and lifetime offers on purchase history.

Show the yearly free trial only to users who have never purchased, hide lifetime after any prior purchase, and remove launch-session tracking in favor of StoreKit transaction history.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 هفته پیش
والد
کامیت
9a385d0c23

+ 0 - 40
App AI for Reddit/Managers/AppUsageManager.swift

@@ -1,40 +0,0 @@
-import AppKit
-import Combine
-import Foundation
-
-@MainActor
-final class AppUsageManager {
-    static let shared = AppUsageManager()
-
-    static let didCompleteFirstSession = Notification.Name("AppUsageManager.didCompleteFirstSession")
-
-    private static let hasCompletedFirstSessionKey = "AppUsageManager.hasCompletedFirstSession"
-
-    private let defaults: UserDefaults
-    private var cancellables = Set<AnyCancellable>()
-
-    private init(defaults: UserDefaults = .standard) {
-        self.defaults = defaults
-    }
-
-    /// `true` until the user has finished their first app session.
-    var isFirstTimeUser: Bool {
-        !defaults.bool(forKey: Self.hasCompletedFirstSessionKey)
-    }
-
-    func start() {
-        guard cancellables.isEmpty else { return }
-
-        NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification)
-            .sink { [weak self] _ in
-                self?.markFirstSessionCompleted()
-            }
-            .store(in: &cancellables)
-    }
-
-    func markFirstSessionCompleted() {
-        guard isFirstTimeUser else { return }
-        defaults.set(true, forKey: Self.hasCompletedFirstSessionKey)
-        NotificationCenter.default.post(name: Self.didCompleteFirstSession, object: nil)
-    }
-}

+ 37 - 15
App AI for Reddit/Managers/SubscriptionManager.swift

@@ -27,6 +27,7 @@ final class SubscriptionManager: ObservableObject {
 
     private static let cachedPremiumKey = "SubscriptionManager.cachedIsPremium"
     private static let cachedPremiumPlanKey = "SubscriptionManager.cachedPremiumPlan"
+    private static let cachedHasEverPurchasedKey = "SubscriptionManager.cachedHasEverPurchased"
 
     @Published private(set) var productsByID: [String: Product] = [:]
     @Published private(set) var isLoadingProducts = false
@@ -37,8 +38,16 @@ final class SubscriptionManager: ObservableObject {
     @Published private(set) var activePremiumPlan: PaywallPlan?
     @Published private(set) var productLoadError: String?
     @Published private(set) var introOfferEligibleByProductID: [String: Bool] = [:]
+    @Published private(set) var hasEverPurchasedPremium = false
     @Published var purchaseError: PurchaseError?
 
+    var availablePaywallPlans: [PaywallPlan] {
+        if hasEverPurchasedPremium {
+            return PaywallPlan.allCases.filter { $0 != .lifetime }
+        }
+        return PaywallPlan.allCases
+    }
+
     var isResolvingEntitlements: Bool {
         !hasResolvedPremiumStatus
     }
@@ -58,14 +67,10 @@ final class SubscriptionManager: ObservableObject {
     private var inFlightProductLoad: Task<Void, Never>?
     private var initialEntitlementTask: Task<Void, Never>?
     private var hasStarted = false
-    private var cancellables = Set<AnyCancellable>()
-
-    var isFirstTimeAppUser: Bool {
-        AppUsageManager.shared.isFirstTimeUser
-    }
 
     init() {
         hasPremiumAccess = UserDefaults.standard.bool(forKey: Self.cachedPremiumKey)
+        hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: Self.cachedHasEverPurchasedKey)
         if let raw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
            let plan = PaywallPlan(rawValue: raw) {
             activePremiumPlan = plan
@@ -76,14 +81,6 @@ final class SubscriptionManager: ObservableObject {
         guard !hasStarted else { return }
         hasStarted = true
 
-        AppUsageManager.shared.start()
-
-        NotificationCenter.default.publisher(for: AppUsageManager.didCompleteFirstSession)
-            .sink { [weak self] _ in
-                self?.objectWillChange.send()
-            }
-            .store(in: &cancellables)
-
         Task { await listenForTransactionUpdates() }
 
         initialEntitlementTask = Task {
@@ -102,7 +99,7 @@ final class SubscriptionManager: ObservableObject {
     }
 
     func eligibleIntroOffer(for product: Product) -> Product.SubscriptionOffer? {
-        guard isFirstTimeAppUser,
+        guard !hasEverPurchasedPremium,
               introOfferEligibleByProductID[product.id] == true,
               let offer = product.subscription?.introductoryOffer,
               offer.paymentMode == .freeTrial else {
@@ -145,7 +142,7 @@ final class SubscriptionManager: ObservableObject {
             return plan.fallbackBillingDescription(config: config)
         }
 
-        if isFirstTimeAppUser,
+        if !hasEverPurchasedPremium,
            let subscription = product.subscription,
            let intro = subscription.introductoryOffer,
            intro.paymentMode == .freeTrial,
@@ -168,6 +165,11 @@ final class SubscriptionManager: ObservableObject {
     func purchase(_ plan: PaywallPlan) async -> Bool {
         purchaseError = nil
 
+        guard availablePaywallPlans.contains(plan) else {
+            purchaseError = .planUnavailable
+            return false
+        }
+
         var product = productsByID[plan.productID]
         if product == nil {
             await loadProducts(presentLoadingUI: false)
@@ -344,6 +346,7 @@ final class SubscriptionManager: ObservableObject {
         hasPremiumAccess = finalPlan != nil
         hasResolvedPremiumStatus = true
         persistPremiumCache()
+        await refreshPurchaseHistory()
     }
 
     /// Re-reads entitlements from StoreKit, then syncs with the App Store when needed.
@@ -387,6 +390,7 @@ final class SubscriptionManager: ObservableObject {
         activePremiumPlan = preferredPlan(activePremiumPlan, plan)
         hasPremiumAccess = true
         hasResolvedPremiumStatus = true
+        markAsHavingPurchasedPremium()
         persistPremiumCache()
     }
 
@@ -411,9 +415,27 @@ final class SubscriptionManager: ObservableObject {
         activePremiumPlan = preferredPlan(activePremiumPlan, plan)
         hasPremiumAccess = true
         hasResolvedPremiumStatus = true
+        markAsHavingPurchasedPremium()
         persistPremiumCache()
     }
 
+    private func markAsHavingPurchasedPremium() {
+        guard !hasEverPurchasedPremium else { return }
+        hasEverPurchasedPremium = true
+        UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
+    }
+
+    private func refreshPurchaseHistory() async {
+        if hasEverPurchasedPremium { return }
+
+        for await result in Transaction.all {
+            guard case .verified(let transaction) = result else { continue }
+            guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
+            markAsHavingPurchasedPremium()
+            return
+        }
+    }
+
     private func persistPremiumCache() {
         UserDefaults.standard.set(hasPremiumAccess, forKey: Self.cachedPremiumKey)
         UserDefaults.standard.set(activePremiumPlan?.rawValue, forKey: Self.cachedPremiumPlanKey)

+ 7 - 0
App AI for Reddit/ViewModels/PaywallViewModel.swift

@@ -8,4 +8,11 @@ final class PaywallViewModel {
     func selectPlan(_ plan: PaywallPlan) {
         selectedPlan = plan
     }
+
+    func syncSelectedPlan(with availablePlans: [PaywallPlan]) {
+        guard !availablePlans.isEmpty else { return }
+        if !availablePlans.contains(selectedPlan) {
+            selectedPlan = availablePlans.contains(.yearly) ? .yearly : availablePlans[0]
+        }
+    }
 }

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

@@ -44,7 +44,6 @@ struct FrontPageView: View {
 
             if viewModel.isPaywallPresented {
                 PaywallView(viewModel: paywallViewModel) {
-                    AppUsageManager.shared.markFirstSessionCompleted()
                     viewModel.dismissPaywall()
                 }
                 .frame(maxWidth: .infinity, maxHeight: .infinity)

+ 9 - 1
App AI for Reddit/Views/PaywallView.swift

@@ -31,6 +31,10 @@ struct PaywallView: View {
         subscriptions.product(for: viewModel.selectedPlan) != nil
     }
 
+    private var availablePlans: [PaywallPlan] {
+        subscriptions.availablePaywallPlans
+    }
+
     var body: some View {
         GeometryReader { geometry in
             let metrics = PaywallLayoutMetrics(size: geometry.size)
@@ -79,6 +83,10 @@ struct PaywallView: View {
                 await subscriptions.loadProducts()
             }
             await paywallConfigService.refreshFromRemote()
+            viewModel.syncSelectedPlan(with: availablePlans)
+        }
+        .onChange(of: subscriptions.hasEverPurchasedPremium) { _, _ in
+            viewModel.syncSelectedPlan(with: availablePlans)
         }
         .onReceive(NotificationCenter.default.publisher(for: .paywallConfigDidUpdate)) { _ in }
         .alert(
@@ -173,7 +181,7 @@ struct PaywallView: View {
 
     private func pricingCards(metrics: PaywallLayoutMetrics) -> some View {
         HStack(alignment: .top, spacing: metrics.cardSpacing) {
-            ForEach(PaywallPlan.allCases) { plan in
+            ForEach(availablePlans) { plan in
                 PaywallPricingCard(
                     plan: plan,
                     config: config,