Ver Fonte

Fix paywall and StoreKit behavior after refunds and testing.

Always show monthly, yearly, and lifetime plans, remove the yearly free trial, stop repeated Apple ID prompts by avoiding background App Store sync, and let AI tools use template mode without blocking on the paywall.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 há 3 semanas atrás
pai
commit
68bb875bb8

+ 71 - 135
App AI for Reddit/Managers/SubscriptionManager.swift

@@ -37,15 +37,11 @@ final class SubscriptionManager: ObservableObject {
     @Published private(set) var hasResolvedPremiumStatus = false
     @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
+        PaywallPlan.allCases
     }
 
     var isResolvingEntitlements: Bool {
@@ -66,21 +62,23 @@ final class SubscriptionManager: ObservableObject {
 
     private var inFlightProductLoad: Task<Void, Never>?
     private var initialEntitlementTask: Task<Void, Never>?
+    private var entitlementRefreshTask: Task<Void, Never>?
     private var hasStarted = false
+    private var cancellables = Set<AnyCancellable>()
+    private var lastBackgroundEntitlementRefresh: Date?
+
+    /// Minimum time between entitlement re-checks when the app regains focus.
+    private static let backgroundRefreshInterval: TimeInterval = 120
 
     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
-        }
     }
 
     func start() {
         guard !hasStarted else { return }
         hasStarted = true
 
+        observeAppLifecycle()
         Task { await listenForTransactionUpdates() }
 
         initialEntitlementTask = Task {
@@ -89,23 +87,33 @@ final class SubscriptionManager: ObservableObject {
         }
     }
 
-    func ensureEntitlementsResolved() async {
-        await initialEntitlementTask?.value
+    private func observeAppLifecycle() {
+        NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)
+            .sink { [weak self] _ in
+                self?.refreshEntitlementsOnAppActive()
+            }
+            .store(in: &cancellables)
     }
 
-    func eligibleIntroOffer(for plan: PaywallPlan) -> Product.SubscriptionOffer? {
-        guard let product = product(for: plan) else { return nil }
-        return eligibleIntroOffer(for: product)
-    }
+    private func refreshEntitlementsOnAppActive() {
+        guard hasStarted else { return }
 
-    func eligibleIntroOffer(for product: Product) -> Product.SubscriptionOffer? {
-        guard !hasEverPurchasedPremium,
-              introOfferEligibleByProductID[product.id] == true,
-              let offer = product.subscription?.introductoryOffer,
-              offer.paymentMode == .freeTrial else {
-            return nil
+        if let lastRefresh = lastBackgroundEntitlementRefresh,
+           Date().timeIntervalSince(lastRefresh) < Self.backgroundRefreshInterval {
+            return
+        }
+
+        entitlementRefreshTask?.cancel()
+        entitlementRefreshTask = Task {
+            // Local StoreKit entitlements only — AppStore.sync() prompts for Apple ID
+            // and can loop in Xcode when focus returns after dismissing the dialog.
+            await refreshPremiumAccess()
+            lastBackgroundEntitlementRefresh = Date()
         }
-        return offer
+    }
+
+    func ensureEntitlementsResolved() async {
+        await initialEntitlementTask?.value
     }
 
     func loadProducts(presentLoadingUI: Bool? = nil) async {
@@ -142,16 +150,6 @@ final class SubscriptionManager: ObservableObject {
             return plan.fallbackBillingDescription(config: config)
         }
 
-        if !hasEverPurchasedPremium,
-           let subscription = product.subscription,
-           let intro = subscription.introductoryOffer,
-           intro.paymentMode == .freeTrial,
-           introOfferEligibleByProductID[product.id] == true {
-            let trialPeriod = formattedPeriod(intro.period)
-            let billingPeriod = formattedSubscriptionPeriod(subscription.subscriptionPeriod)
-            return "\(trialPeriod) Free Trial, then \(product.displayPrice) per \(billingPeriod)"
-        }
-
         switch plan {
         case .monthly:
             return "Billed at \(product.displayPrice) every month"
@@ -290,7 +288,6 @@ final class SubscriptionManager: ObservableObject {
             }
             productsByID = map
             productLoadError = nil
-            await refreshIntroOfferEligibility()
             await refreshPremiumAccess()
         } catch is ProductLoadError {
             productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
@@ -349,28 +346,24 @@ final class SubscriptionManager: ObservableObject {
         await refreshPurchaseHistory()
     }
 
-    /// Re-reads entitlements from StoreKit, then syncs with the App Store when needed.
-    /// A verified purchase is never downgraded while the network is still catching up.
+    /// Re-reads entitlements from StoreKit, then syncs with the App Store when a
+    /// purchase was just verified but entitlements have not caught up yet.
     private func reconcilePremiumAccess(trustedPlan: PaywallPlan?) async {
         await refreshPremiumAccess(trustedPlan: trustedPlan)
 
         guard let trustedPlan, !hasPremiumAccess else { return }
 
-        for attempt in 1...3 {
-            do {
-                try await syncAppStoreWithTimeout(seconds: 15)
-            } catch {
-                continue
-            }
-
-            await refreshPremiumAccess(trustedPlan: trustedPlan)
-            if hasPremiumAccess { return }
-
-            let delayNanoseconds = UInt64(attempt) * 1_500_000_000
-            try? await Task.sleep(nanoseconds: delayNanoseconds)
+        do {
+            try await syncAppStoreWithTimeout(seconds: 15)
+        } catch {
+            applyTrustedPlan(trustedPlan)
+            return
         }
 
-        applyTrustedPlan(trustedPlan)
+        await refreshPremiumAccess(trustedPlan: trustedPlan)
+        if !hasPremiumAccess {
+            applyTrustedPlan(trustedPlan)
+        }
     }
 
     private func mergeTrustedPlan(_ resolvedPlan: PaywallPlan?, _ trustedPlan: PaywallPlan?) -> PaywallPlan? {
@@ -394,6 +387,13 @@ final class SubscriptionManager: ObservableObject {
         persistPremiumCache()
     }
 
+    private func revokePremiumAccess() {
+        activePremiumPlan = nil
+        hasPremiumAccess = false
+        hasResolvedPremiumStatus = true
+        persistPremiumCache()
+    }
+
     private func syncAppStoreWithTimeout(seconds: TimeInterval) async throws {
         try await withThrowingTaskGroup(of: Void.self) { group in
             group.addTask {
@@ -438,22 +438,11 @@ final class SubscriptionManager: ObservableObject {
 
     private func persistPremiumCache() {
         UserDefaults.standard.set(hasPremiumAccess, forKey: Self.cachedPremiumKey)
-        UserDefaults.standard.set(activePremiumPlan?.rawValue, forKey: Self.cachedPremiumPlanKey)
-    }
-
-    private func refreshIntroOfferEligibility() async {
-        var eligibility: [String: Bool] = [:]
-
-        for product in productsByID.values {
-            guard let subscription = product.subscription,
-                  subscription.introductoryOffer != nil else {
-                continue
-            }
-            let groupID = subscription.subscriptionGroupID
-            eligibility[product.id] = await Product.SubscriptionInfo.isEligibleForIntroOffer(for: groupID)
+        if let plan = activePremiumPlan?.rawValue {
+            UserDefaults.standard.set(plan, forKey: Self.cachedPremiumPlanKey)
+        } else {
+            UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
         }
-
-        introOfferEligibleByProductID = eligibility
     }
 
     private func planGrantingPremium(from transaction: StoreKit.Transaction) -> PaywallPlan? {
@@ -474,10 +463,10 @@ final class SubscriptionManager: ObservableObject {
         if current == .lifetime || candidate == .lifetime {
             return .lifetime
         }
-        if let current {
-            if current == .yearly || candidate == .yearly {
-                return .yearly
-            }
+        if current == .yearly || candidate == .yearly {
+            return .yearly
+        }
+        if current == .monthly || candidate == .monthly {
             return .monthly
         }
         return candidate
@@ -500,89 +489,36 @@ final class SubscriptionManager: ObservableObject {
             resolvedPlan = preferredPlan(resolvedPlan, plan)
         }
 
-        if resolvedPlan == nil, await hasActiveSubscriptionStatus() {
-            resolvedPlan = await subscriptionPlanFromProductStatus()
-        }
-
         return resolvedPlan
     }
 
-    private func subscriptionPlanFromProductStatus() async -> PaywallPlan? {
-        if productsByID.isEmpty {
-            do {
-                let loaded = try await Product.products(for: SubscriptionProductID.all)
-                var map: [String: Product] = [:]
-                for product in loaded { map[product.id] = product }
-                productsByID = map
-            } catch {
-                return nil
-            }
-        }
-
-        for product in productsByID.values {
-            guard let subscription = product.subscription else { continue }
-            guard let plan = SubscriptionProductID.plan(for: product.id), plan != .lifetime else { continue }
-
-            do {
-                let statuses = try await subscription.status
-                let isActive = statuses.contains { status in
-                    switch status.state {
-                    case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
-                        return true
-                    case .expired, .revoked:
-                        return false
-                    default:
-                        return false
-                    }
-                }
-                if isActive { return plan }
-            } catch {
-                continue
-            }
-        }
-
-        return nil
-    }
-
-    private func hasActiveSubscriptionStatus() async -> Bool {
-        await subscriptionPlanFromProductStatus() != nil
-    }
-
     private func listenForTransactionUpdates() async {
         for await update in Transaction.updates {
             guard case .verified(let transaction) = update else { continue }
 
+            if isRevokedAppTransaction(transaction) {
+                revokePremiumAccess()
+                await transaction.finish()
+                await refreshPremiumAccess()
+                continue
+            }
+
             let trustedPlan = planGrantingPremium(from: transaction)
             if let trustedPlan {
                 applyPremiumAccess(from: transaction)
             }
 
             await transaction.finish()
-            await reconcilePremiumAccess(trustedPlan: trustedPlan)
-        }
-    }
 
-    private func formattedPeriod(_ period: Product.SubscriptionPeriod) -> String {
-        switch period.unit {
-        case .day where period.value == 1: "1 Day"
-        case .day: "\(period.value) Days"
-        case .week where period.value == 1: "1 Week"
-        case .week: "\(period.value) Weeks"
-        case .month where period.value == 1: "1 Month"
-        case .month: "\(period.value) Months"
-        case .year where period.value == 1: "1 Year"
-        case .year: "\(period.value) Years"
-        @unknown default: "\(period.value) Days"
+            if trustedPlan != nil {
+                await reconcilePremiumAccess(trustedPlan: trustedPlan)
+            } else {
+                await refreshPremiumAccess()
+            }
         }
     }
 
-    private func formattedSubscriptionPeriod(_ period: Product.SubscriptionPeriod) -> String {
-        switch period.unit {
-        case .day: period.value == 1 ? "day" : "\(period.value) days"
-        case .week: period.value == 1 ? "week" : "\(period.value) weeks"
-        case .month: period.value == 1 ? "month" : "\(period.value) months"
-        case .year: period.value == 1 ? "year" : "\(period.value) years"
-        @unknown default: "billing period"
-        }
+    private func isRevokedAppTransaction(_ transaction: StoreKit.Transaction) -> Bool {
+        SubscriptionProductID.all.contains(transaction.productID) && transaction.revocationDate != nil
     }
 }

+ 5 - 41
App AI for Reddit/Models/PaywallModels.swift

@@ -28,46 +28,19 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
         product?.displayPrice ?? planCopy(from: config).fallbackPrice
     }
 
-    func localizedSubtitle(
-        from product: Product?,
-        config: PaywallConfig,
-        introOffer: Product.SubscriptionOffer? = nil
-    ) -> String {
+    func localizedSubtitle(from product: Product?, config: PaywallConfig) -> String {
         guard let product else { return config.loadingPrice }
         let copy = planCopy(from: config)
-        let price = product.displayPrice
-
-        if self == .yearly, let introOffer, let trialTemplate = copy.trialSubtitleTemplate {
-            let duration = config.trialDurationDescription(from: introOffer, lowercase: true)
-            return trialTemplate
-                .replacingOccurrences(of: "{trial}", with: duration)
-                .replacingOccurrences(of: "{price}", with: price)
-        }
-
-        return copy.subtitleTemplate.replacingOccurrences(of: "{price}", with: price)
+        return copy.subtitleTemplate.replacingOccurrences(of: "{price}", with: product.displayPrice)
     }
 
-    func localizedCTATitle(
-        from product: Product?,
-        config: PaywallConfig,
-        introOffer: Product.SubscriptionOffer? = nil
-    ) -> String {
+    func localizedCTATitle(from product: Product?, config: PaywallConfig) -> String {
         guard let product else { return config.loadingCTA }
         let copy = planCopy(from: config)
-        let price = product.displayPrice
-
-        if self == .yearly, let introOffer {
-            return config.trialCTATitle(from: introOffer)
-        }
-
-        return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: price)
+        return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: product.displayPrice)
     }
 
-    func localizedRenewalDisclosure(
-        from product: Product?,
-        config: PaywallConfig,
-        introOffer: Product.SubscriptionOffer? = nil
-    ) -> String {
+    func localizedRenewalDisclosure(from product: Product?, config: PaywallConfig) -> String {
         switch self {
         case .lifetime:
             return config.lifetimeDisclosure
@@ -77,15 +50,6 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
             let period = product.subscription.map {
                 config.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
             } ?? config.messages.periodFallback
-
-            if self == .yearly, let introOffer {
-                let trial = config.trialDurationDescription(from: introOffer, lowercase: true)
-                return config.trialDisclosureTemplate
-                    .replacingOccurrences(of: "{trial}", with: trial)
-                    .replacingOccurrences(of: "{price}", with: price)
-                    .replacingOccurrences(of: "{period}", with: period)
-            }
-
             return config.subscriptionDisclosureTemplate
                 .replacingOccurrences(of: "{price}", with: price)
                 .replacingOccurrences(of: "{period}", with: period)

+ 1 - 6
App AI for Reddit/Paywall.storekit

@@ -119,12 +119,7 @@
           "familyShareable" : false,
           "groupNumber" : 2,
           "internalID" : "6748291003",
-          "introductoryOffer" : {
-            "internalID" : "6748291004",
-            "numberOfPeriods" : 1,
-            "paymentMode" : "free",
-            "subscriptionPeriod" : "P1W"
-          },
+          "introductoryOffer" : null,
           "localizations" : [
             {
               "description" : "Yearly access to all App AI for Reddit Pro features.",

+ 0 - 3
App AI for Reddit/Views/CommentWriterView.swift

@@ -1,7 +1,6 @@
 import SwiftUI
 
 struct CommentWriterView: View {
-    @Environment(\.requirePremiumAccess) private var requirePremiumAccess
     @Bindable var viewModel: CommentWriterViewModel
 
     private enum Layout {
@@ -42,7 +41,6 @@ struct CommentWriterView: View {
                 viewModel.cancelOverwriteConfirmation()
             }
             Button("Replace", role: .destructive) {
-                guard requirePremiumAccess() else { return }
                 Task { await viewModel.confirmOverwriteAndGenerate() }
             }
         } message: {
@@ -125,7 +123,6 @@ struct CommentWriterView: View {
             .disabled(!viewModel.canExport)
 
             Button {
-                guard requirePremiumAccess() else { return }
                 Task { await viewModel.generateComment() }
             } label: {
                 HStack(spacing: 6) {

+ 2 - 5
App AI for Reddit/Views/FrontPageView.swift

@@ -34,7 +34,6 @@ struct FrontPageView: View {
             .fullScreenLayoutWhenNeeded()
             .environment(\.requirePremiumAccess, {
                 if subscriptions.isResolvingEntitlements {
-                    if subscriptions.hasPremiumAccess { return true }
                     return false
                 }
                 if subscriptions.hasPremiumAccess { return true }
@@ -55,10 +54,8 @@ struct FrontPageView: View {
         .animation(.easeInOut(duration: 0.2), value: viewModel.isPaywallPresented)
         .frame(maxWidth: .infinity, maxHeight: .infinity)
         .frame(minWidth: AppWindow.minWidth, minHeight: AppWindow.minHeight)
-        .onChange(of: subscriptions.hasPremiumAccess) { hasPremium in
-            if hasPremium {
-                viewModel.dismissPaywall()
-            }
+        .onChange(of: subscriptions.hasPremiumAccess) { _, hasPremium in
+            viewModel.dismissPaywall()
             syncToolPremiumAccess()
             appRating.updatePremiumStatus(hasPremium)
             if subscriptions.hasResolvedPremiumStatus {

+ 5 - 20
App AI for Reddit/Views/PaywallView.swift

@@ -78,8 +78,8 @@ struct PaywallView: View {
         .ignoresSafeArea()
         .task {
             await subscriptions.ensureEntitlementsResolved()
-            if subscriptions.productsByID.isEmpty, !subscriptions.isLoadingProducts {
-                await subscriptions.loadProducts()
+            if !subscriptions.hasAllProductsLoaded, !subscriptions.isLoadingProducts {
+                await subscriptions.loadProducts(presentLoadingUI: true)
             }
             await paywallConfigService.refreshFromRemote()
             viewModel.syncSelectedPlan(with: availablePlans)
@@ -185,7 +185,6 @@ struct PaywallView: View {
                     plan: plan,
                     config: config,
                     product: subscriptions.product(for: plan),
-                    introOffer: subscriptions.eligibleIntroOffer(for: plan),
                     isSelected: viewModel.selectedPlan == plan,
                     metrics: metrics
                 ) {
@@ -199,15 +198,13 @@ struct PaywallView: View {
 
     private func ctaSection(metrics: PaywallLayoutMetrics) -> some View {
         let selectedProduct = subscriptions.product(for: viewModel.selectedPlan)
-        let introOffer = subscriptions.eligibleIntroOffer(for: viewModel.selectedPlan)
         let ctaDisabled = buttonsBusy || subscriptions.isLoadingProducts || !isProductReady || hasProductLoadFailure
 
         return VStack(spacing: metrics.ctaSpacing) {
             Text(
                 viewModel.selectedPlan.localizedRenewalDisclosure(
                     from: selectedProduct,
-                    config: config,
-                    introOffer: introOffer
+                    config: config
                 )
             )
             .font(.system(size: metrics.footerNoteFontSize))
@@ -231,8 +228,7 @@ struct PaywallView: View {
                     Text(
                         viewModel.selectedPlan.localizedCTATitle(
                             from: selectedProduct,
-                            config: config,
-                            introOffer: introOffer
+                            config: config
                         )
                     )
                     .font(.system(size: metrics.ctaFontSize, weight: .bold))
@@ -402,7 +398,6 @@ private struct PaywallPricingCard: View {
     let plan: PaywallPlan
     let config: PaywallConfig
     let product: Product?
-    let introOffer: Product.SubscriptionOffer?
     let isSelected: Bool
     let metrics: PaywallLayoutMetrics
     let onSelect: () -> Void
@@ -422,7 +417,7 @@ private struct PaywallPricingCard: View {
                     .font(.system(size: metrics.ctaFontSize, weight: .semibold))
                     .foregroundStyle(AppTheme.textPrimary)
 
-                Text(plan.localizedSubtitle(from: product, config: config, introOffer: introOffer))
+                Text(plan.localizedSubtitle(from: product, config: config))
                     .font(.system(size: metrics.featureFontSize * 0.85))
                     .foregroundStyle(AppTheme.textTertiary)
                     .fixedSize(horizontal: false, vertical: true)
@@ -485,16 +480,6 @@ private struct PaywallPricingCard: View {
                         .clipShape(Capsule())
                         .padding(.top, metrics.cardPadding * 0.65)
                         .padding(.trailing, metrics.cardPadding * 0.65)
-                } else if plan == .yearly, let introOffer {
-                    Text(config.trialBadgeText(from: introOffer))
-                        .font(.system(size: metrics.featureFontSize * 0.75, weight: .bold))
-                        .foregroundStyle(Color(hex: 0x0B0E14))
-                        .padding(.horizontal, 10)
-                        .padding(.vertical, 4)
-                        .background(AppTheme.accentPurpleLight)
-                        .clipShape(Capsule())
-                        .padding(.top, metrics.cardPadding * 0.65)
-                        .padding(.trailing, metrics.cardPadding * 0.65)
                 }
             }
         }

+ 0 - 3
App AI for Reddit/Views/PostGeneratorView.swift

@@ -3,7 +3,6 @@ import SwiftUI
 import UniformTypeIdentifiers
 
 struct PostGeneratorView: View {
-    @Environment(\.requirePremiumAccess) private var requirePremiumAccess
     @Bindable var viewModel: PostGeneratorViewModel
 
     private enum Layout {
@@ -57,7 +56,6 @@ struct PostGeneratorView: View {
                 viewModel.cancelOverwriteConfirmation()
             }
             Button("Replace", role: .destructive) {
-                guard requirePremiumAccess() else { return }
                 Task { await viewModel.confirmOverwriteAndGenerate() }
             }
         } message: {
@@ -142,7 +140,6 @@ struct PostGeneratorView: View {
             .disabled(!viewModel.canExport)
 
             Button {
-                guard requirePremiumAccess() else { return }
                 Task { await viewModel.generatePost() }
             } label: {
                 HStack(spacing: 6) {

+ 0 - 2
App AI for Reddit/Views/TitleOptimizerView.swift

@@ -1,7 +1,6 @@
 import SwiftUI
 
 struct TitleOptimizerView: View {
-    @Environment(\.requirePremiumAccess) private var requirePremiumAccess
     @Bindable var viewModel: TitleOptimizerViewModel
 
     private enum Layout {
@@ -107,7 +106,6 @@ struct TitleOptimizerView: View {
             .disabled(viewModel.activeTitle.trimmingCharacters(in: .whitespaces).isEmpty)
 
             Button {
-                guard requirePremiumAccess() else { return }
                 Task { await viewModel.optimizeTitles() }
             } label: {
                 HStack(spacing: 6) {

+ 0 - 1
App AI for Reddit/paywall.json

@@ -54,7 +54,6 @@
     "yearly": {
       "title": "Yearly",
       "subtitleTemplate": "{price} / year, cancel anytime",
-      "trialSubtitleTemplate": "Get {trial} free, then {price} / year",
       "ctaTemplate": "Get Premium for {price} / Year",
       "fallbackPrice": "$49.99",
       "priceSuffix": "/yr",