Переглянути джерело

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 cachedPremiumKey = "SubscriptionManager.cachedIsPremium"
     private static let cachedPremiumPlanKey = "SubscriptionManager.cachedPremiumPlan"
     private static let cachedPremiumPlanKey = "SubscriptionManager.cachedPremiumPlan"
+    private static let cachedHasEverPurchasedKey = "SubscriptionManager.cachedHasEverPurchased"
 
 
     @Published private(set) var productsByID: [String: Product] = [:]
     @Published private(set) var productsByID: [String: Product] = [:]
     @Published private(set) var isLoadingProducts = false
     @Published private(set) var isLoadingProducts = false
@@ -37,8 +38,16 @@ final class SubscriptionManager: ObservableObject {
     @Published private(set) var activePremiumPlan: PaywallPlan?
     @Published private(set) var activePremiumPlan: PaywallPlan?
     @Published private(set) var productLoadError: String?
     @Published private(set) var productLoadError: String?
     @Published private(set) var introOfferEligibleByProductID: [String: Bool] = [:]
     @Published private(set) var introOfferEligibleByProductID: [String: Bool] = [:]
+    @Published private(set) var hasEverPurchasedPremium = false
     @Published var purchaseError: PurchaseError?
     @Published var purchaseError: PurchaseError?
 
 
+    var availablePaywallPlans: [PaywallPlan] {
+        if hasEverPurchasedPremium {
+            return PaywallPlan.allCases.filter { $0 != .lifetime }
+        }
+        return PaywallPlan.allCases
+    }
+
     var isResolvingEntitlements: Bool {
     var isResolvingEntitlements: Bool {
         !hasResolvedPremiumStatus
         !hasResolvedPremiumStatus
     }
     }
@@ -58,14 +67,10 @@ final class SubscriptionManager: ObservableObject {
     private var inFlightProductLoad: Task<Void, Never>?
     private var inFlightProductLoad: Task<Void, Never>?
     private var initialEntitlementTask: Task<Void, Never>?
     private var initialEntitlementTask: Task<Void, Never>?
     private var hasStarted = false
     private var hasStarted = false
-    private var cancellables = Set<AnyCancellable>()
-
-    var isFirstTimeAppUser: Bool {
-        AppUsageManager.shared.isFirstTimeUser
-    }
 
 
     init() {
     init() {
         hasPremiumAccess = UserDefaults.standard.bool(forKey: Self.cachedPremiumKey)
         hasPremiumAccess = UserDefaults.standard.bool(forKey: Self.cachedPremiumKey)
+        hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: Self.cachedHasEverPurchasedKey)
         if let raw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
         if let raw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
            let plan = PaywallPlan(rawValue: raw) {
            let plan = PaywallPlan(rawValue: raw) {
             activePremiumPlan = plan
             activePremiumPlan = plan
@@ -76,14 +81,6 @@ final class SubscriptionManager: ObservableObject {
         guard !hasStarted else { return }
         guard !hasStarted else { return }
         hasStarted = true
         hasStarted = true
 
 
-        AppUsageManager.shared.start()
-
-        NotificationCenter.default.publisher(for: AppUsageManager.didCompleteFirstSession)
-            .sink { [weak self] _ in
-                self?.objectWillChange.send()
-            }
-            .store(in: &cancellables)
-
         Task { await listenForTransactionUpdates() }
         Task { await listenForTransactionUpdates() }
 
 
         initialEntitlementTask = Task {
         initialEntitlementTask = Task {
@@ -102,7 +99,7 @@ final class SubscriptionManager: ObservableObject {
     }
     }
 
 
     func eligibleIntroOffer(for product: Product) -> Product.SubscriptionOffer? {
     func eligibleIntroOffer(for product: Product) -> Product.SubscriptionOffer? {
-        guard isFirstTimeAppUser,
+        guard !hasEverPurchasedPremium,
               introOfferEligibleByProductID[product.id] == true,
               introOfferEligibleByProductID[product.id] == true,
               let offer = product.subscription?.introductoryOffer,
               let offer = product.subscription?.introductoryOffer,
               offer.paymentMode == .freeTrial else {
               offer.paymentMode == .freeTrial else {
@@ -145,7 +142,7 @@ final class SubscriptionManager: ObservableObject {
             return plan.fallbackBillingDescription(config: config)
             return plan.fallbackBillingDescription(config: config)
         }
         }
 
 
-        if isFirstTimeAppUser,
+        if !hasEverPurchasedPremium,
            let subscription = product.subscription,
            let subscription = product.subscription,
            let intro = subscription.introductoryOffer,
            let intro = subscription.introductoryOffer,
            intro.paymentMode == .freeTrial,
            intro.paymentMode == .freeTrial,
@@ -168,6 +165,11 @@ final class SubscriptionManager: ObservableObject {
     func purchase(_ plan: PaywallPlan) async -> Bool {
     func purchase(_ plan: PaywallPlan) async -> Bool {
         purchaseError = nil
         purchaseError = nil
 
 
+        guard availablePaywallPlans.contains(plan) else {
+            purchaseError = .planUnavailable
+            return false
+        }
+
         var product = productsByID[plan.productID]
         var product = productsByID[plan.productID]
         if product == nil {
         if product == nil {
             await loadProducts(presentLoadingUI: false)
             await loadProducts(presentLoadingUI: false)
@@ -344,6 +346,7 @@ final class SubscriptionManager: ObservableObject {
         hasPremiumAccess = finalPlan != nil
         hasPremiumAccess = finalPlan != nil
         hasResolvedPremiumStatus = true
         hasResolvedPremiumStatus = true
         persistPremiumCache()
         persistPremiumCache()
+        await refreshPurchaseHistory()
     }
     }
 
 
     /// Re-reads entitlements from StoreKit, then syncs with the App Store when needed.
     /// 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)
         activePremiumPlan = preferredPlan(activePremiumPlan, plan)
         hasPremiumAccess = true
         hasPremiumAccess = true
         hasResolvedPremiumStatus = true
         hasResolvedPremiumStatus = true
+        markAsHavingPurchasedPremium()
         persistPremiumCache()
         persistPremiumCache()
     }
     }
 
 
@@ -411,9 +415,27 @@ final class SubscriptionManager: ObservableObject {
         activePremiumPlan = preferredPlan(activePremiumPlan, plan)
         activePremiumPlan = preferredPlan(activePremiumPlan, plan)
         hasPremiumAccess = true
         hasPremiumAccess = true
         hasResolvedPremiumStatus = true
         hasResolvedPremiumStatus = true
+        markAsHavingPurchasedPremium()
         persistPremiumCache()
         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() {
     private func persistPremiumCache() {
         UserDefaults.standard.set(hasPremiumAccess, forKey: Self.cachedPremiumKey)
         UserDefaults.standard.set(hasPremiumAccess, forKey: Self.cachedPremiumKey)
         UserDefaults.standard.set(activePremiumPlan?.rawValue, forKey: Self.cachedPremiumPlanKey)
         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) {
     func selectPlan(_ plan: PaywallPlan) {
         selectedPlan = plan
         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 {
             if viewModel.isPaywallPresented {
                 PaywallView(viewModel: paywallViewModel) {
                 PaywallView(viewModel: paywallViewModel) {
-                    AppUsageManager.shared.markFirstSessionCompleted()
                     viewModel.dismissPaywall()
                     viewModel.dismissPaywall()
                 }
                 }
                 .frame(maxWidth: .infinity, maxHeight: .infinity)
                 .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
         subscriptions.product(for: viewModel.selectedPlan) != nil
     }
     }
 
 
+    private var availablePlans: [PaywallPlan] {
+        subscriptions.availablePaywallPlans
+    }
+
     var body: some View {
     var body: some View {
         GeometryReader { geometry in
         GeometryReader { geometry in
             let metrics = PaywallLayoutMetrics(size: geometry.size)
             let metrics = PaywallLayoutMetrics(size: geometry.size)
@@ -79,6 +83,10 @@ struct PaywallView: View {
                 await subscriptions.loadProducts()
                 await subscriptions.loadProducts()
             }
             }
             await paywallConfigService.refreshFromRemote()
             await paywallConfigService.refreshFromRemote()
+            viewModel.syncSelectedPlan(with: availablePlans)
+        }
+        .onChange(of: subscriptions.hasEverPurchasedPremium) { _, _ in
+            viewModel.syncSelectedPlan(with: availablePlans)
         }
         }
         .onReceive(NotificationCenter.default.publisher(for: .paywallConfigDidUpdate)) { _ in }
         .onReceive(NotificationCenter.default.publisher(for: .paywallConfigDidUpdate)) { _ in }
         .alert(
         .alert(
@@ -173,7 +181,7 @@ struct PaywallView: View {
 
 
     private func pricingCards(metrics: PaywallLayoutMetrics) -> some View {
     private func pricingCards(metrics: PaywallLayoutMetrics) -> some View {
         HStack(alignment: .top, spacing: metrics.cardSpacing) {
         HStack(alignment: .top, spacing: metrics.cardSpacing) {
-            ForEach(PaywallPlan.allCases) { plan in
+            ForEach(availablePlans) { plan in
                 PaywallPricingCard(
                 PaywallPricingCard(
                     plan: plan,
                     plan: plan,
                     config: config,
                     config: config,