소스 검색

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,