// // IAPManager.swift // Reflect // // Refactored StoreKit 2 subscription manager with clean state model. // import Foundation import StoreKit import SwiftUI import os.log // MARK: - Subscription State enum SubscriptionState: Equatable { case unknown case subscribed(expirationDate: Date?, willAutoRenew: Bool) case billingRetry(expirationDate: Date?, willAutoRenew: Bool) case gracePeriod(expirationDate: Date?, willAutoRenew: Bool) case inTrial(daysRemaining: Int) case trialExpired case expired case revoked } // MARK: - IAPManager @MainActor class IAPManager: ObservableObject { // MARK: - Shared Instance /// Shared instance for service-level access (e.g., HealthKit gating) static let shared = IAPManager() // MARK: - Debug Toggle /// Set to `true` to bypass all subscription checks and grant full access (for development only) /// Togglable at runtime in DEBUG builds via Settings > Debug > Bypass Subscription #if DEBUG @Published var bypassSubscription: Bool { didSet { UserDefaults.standard.set(bypassSubscription, forKey: "debug_bypassSubscription") } } #else let bypassSubscription = false #endif // MARK: - Constants static let subscriptionGroupID = "21951685" private let productIdentifiers: Set = [ "com.88oakapps.reflect.IAP.subscriptions.monthly", "com.88oakapps.reflect.IAP.subscriptions.yearly" ] private let trialDays = 30 // MARK: - Published State @Published private(set) var state: SubscriptionState = .unknown @Published private(set) var availableProducts: [Product] = [] @Published private(set) var currentProduct: Product? = nil @Published private(set) var isLoading = false // MARK: - Storage /// Reads firstLaunchDate directly from UserDefaults to ensure we always get the latest value /// (Using @AppStorage in a class doesn't auto-sync when other components update the same key) private var firstLaunchDate: Date { get { GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.firstLaunchDate.rawValue) as? Date ?? Date() } set { GroupUserDefaults.groupDefaults.set(newValue, forKey: UserDefaultsStore.Keys.firstLaunchDate.rawValue) } } // MARK: - Private private var updateListenerTask: Task? /// Last time subscription status was checked (for throttling) private var lastStatusCheckTime: Date? /// Minimum interval between status checks (5 minutes) private let statusCheckInterval: TimeInterval = 300 // MARK: - Computed Properties var isSubscribed: Bool { switch state { case .subscribed, .billingRetry, .gracePeriod: return true default: return false } } var hasFullAccess: Bool { if bypassSubscription { return true } switch state { case .subscribed, .billingRetry, .gracePeriod, .inTrial: return true case .unknown, .trialExpired, .expired, .revoked: return false } } var shouldShowPaywall: Bool { if bypassSubscription { return false } switch state { case .trialExpired, .expired, .revoked: return true case .unknown, .subscribed, .billingRetry, .gracePeriod, .inTrial: return false } } var shouldShowTrialWarning: Bool { if bypassSubscription { return false } if case .inTrial = state { return true } return false } var daysLeftInTrial: Int { if case .inTrial(let days) = state { return days } return 0 } var trialExpirationDate: Date? { Calendar.current.date(byAdding: .day, value: trialDays, to: firstLaunchDate) } /// Products sorted by price (lowest first) var sortedProducts: [Product] { availableProducts.sorted { $0.price < $1.price } } // MARK: - Initialization init() { #if DEBUG self.bypassSubscription = UserDefaults.standard.bool(forKey: "debug_bypassSubscription") #endif restoreCachedSubscriptionState() updateListenerTask = listenForTransactions() Task { await checkSubscriptionStatus() } } deinit { updateListenerTask?.cancel() } // MARK: - Public Methods /// Check subscription status - call on app launch and when becoming active /// Throttled to avoid excessive StoreKit calls on rapid foreground transitions func checkSubscriptionStatus() async { // Throttle: skip if we checked recently (unless state is unknown) if state != .unknown, let lastCheck = lastStatusCheckTime, Date().timeIntervalSince(lastCheck) < statusCheckInterval { return } // Only update isLoading if value actually changes to avoid unnecessary view updates if !isLoading { isLoading = true } defer { if isLoading { isLoading = false } lastStatusCheckTime = Date() } // Fetch available products await loadProducts() // Check for active subscription let hasActiveSubscription = await checkForActiveSubscription() if hasActiveSubscription { // State already set in checkForActiveSubscription — cache it switch state { case .subscribed(let expiration, _), .billingRetry(let expiration, _), .gracePeriod(let expiration, _): cacheSubscriptionExpiration(expiration) default: break } syncSubscriptionStatusToUserDefaults() trackSubscriptionAnalytics(source: "status_check_active") return } // Preserve terminal StoreKit states (expired/revoked) instead of overriding with trial fallback. if case .expired = state { syncSubscriptionStatusToUserDefaults() trackSubscriptionAnalytics(source: "status_check_terminal") return } if case .revoked = state { syncSubscriptionStatusToUserDefaults() trackSubscriptionAnalytics(source: "status_check_terminal") return } // Live check found no active subscription. // Before downgrading, check if cached expiration is still valid (covers offline/transient failures). let cachedExpiration = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue) as? Date if let expiration = cachedExpiration, expiration > Date() { state = .subscribed(expirationDate: expiration, willAutoRenew: false) syncSubscriptionStatusToUserDefaults() trackSubscriptionAnalytics(source: "status_check_cached") return } // Subscription genuinely gone — clear cache and fall back to trial cacheSubscriptionExpiration(nil) updateTrialState() trackSubscriptionAnalytics(source: "status_check_fallback") } /// Sync subscription status to UserDefaults for widget access private func syncSubscriptionStatusToUserDefaults() { let accessValue = bypassSubscription ? true : hasFullAccess GroupUserDefaults.groupDefaults.set(accessValue, forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue) } /// Cache subscription state so premium access survives offline/failed checks private func cacheSubscriptionExpiration(_ date: Date?) { GroupUserDefaults.groupDefaults.set(date, forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue) } /// Restore cached subscription state on launch (before async check completes) private func restoreCachedSubscriptionState() { let hasActive = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue) guard hasActive else { return } let cachedExpiration = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue) as? Date // If we have a cached expiration and it's still in the future, restore subscribed state if let expiration = cachedExpiration, expiration > Date() { state = .subscribed(expirationDate: expiration, willAutoRenew: false) } else if cachedExpiration == nil && hasActive { // Had access but no expiration cached (e.g., upgraded from older version) — trust it state = .subscribed(expirationDate: nil, willAutoRenew: false) } } /// Restore purchases func restore(source: String = "settings") async { do { try await AppStore.sync() await checkSubscriptionStatus() AnalyticsManager.shared.trackPurchaseRestored(source: source) trackSubscriptionAnalytics(source: "restore") } catch { AnalyticsManager.shared.trackPurchaseFailed(productId: nil, source: source, error: error.localizedDescription) AppLogger.iap.error("Failed to restore purchases: \(error.localizedDescription)") } } // MARK: - Private Methods private func loadProducts() async { do { let products = try await Product.products(for: productIdentifiers) availableProducts = products.filter { $0.type == .autoRenewable } } catch { AppLogger.iap.error("Failed to load products: \(error.localizedDescription)") } } private func checkForActiveSubscription() async -> Bool { var nonActiveState: SubscriptionState? for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { continue } // Skip revoked transactions if transaction.revocationDate != nil { continue } // Check if this is one of our subscription products guard productIdentifiers.contains(transaction.productID) else { continue } // Get the product for this transaction currentProduct = availableProducts.first { $0.id == transaction.productID } // Get renewal info if let product = currentProduct, let subscription = product.subscription, let statuses = try? await subscription.status { var hadVerifiedStatus = false for status in statuses { guard case .verified(let renewalInfo) = status.renewalInfo else { continue } hadVerifiedStatus = true switch status.state { case .subscribed: state = .subscribed( expirationDate: transaction.expirationDate, willAutoRenew: renewalInfo.willAutoRenew ) return true case .inBillingRetryPeriod: state = .billingRetry( expirationDate: transaction.expirationDate, willAutoRenew: renewalInfo.willAutoRenew ) return true case .inGracePeriod: state = .gracePeriod( expirationDate: transaction.expirationDate, willAutoRenew: renewalInfo.willAutoRenew ) return true case .expired: nonActiveState = .expired continue case .revoked: nonActiveState = .revoked continue default: continue } } // We had detailed status and none were active, so do not fallback to subscribed. if hadVerifiedStatus { continue } } // Fallback if we couldn't get detailed status state = .subscribed(expirationDate: transaction.expirationDate, willAutoRenew: false) return true } // No active subscription found if let nonActiveState { state = nonActiveState } else { currentProduct = nil } return false } #if DEBUG /// Reset subscription state for UI testing. Called after group defaults are cleared /// so that stale cached state from previous test runs is discarded. func resetForTesting() { state = .unknown lastStatusCheckTime = nil currentProduct = nil availableProducts = [] // Explicitly clear cached subscription state to prevent async // checkSubscriptionStatus from restoring stale .subscribed state. GroupUserDefaults.groupDefaults.removeObject(forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue) GroupUserDefaults.groupDefaults.set(false, forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue) GroupUserDefaults.groupDefaults.synchronize() updateTrialState() } #endif private func updateTrialState() { let daysSinceInstall = Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0 let daysRemaining = trialDays - daysSinceInstall if daysRemaining > 0 { state = .inTrial(daysRemaining: daysRemaining) } else { state = .trialExpired } syncSubscriptionStatusToUserDefaults() } // MARK: - Analytics func trackSubscriptionAnalytics(source: String) { let status: String let isSubscribed: Bool let hasFullAccess: Bool let productId = currentProduct?.id let type = subscriptionType(for: productId) let willAutoRenew: Bool? let isInGracePeriod: Bool? let trialDaysRemaining: Int? let expirationDate: Date? switch state { case .unknown: status = "unknown" isSubscribed = false hasFullAccess = bypassSubscription willAutoRenew = nil isInGracePeriod = nil trialDaysRemaining = nil expirationDate = nil case .subscribed(let expiration, let autoRenew): status = "subscribed" isSubscribed = true hasFullAccess = true willAutoRenew = autoRenew isInGracePeriod = false trialDaysRemaining = nil expirationDate = expiration case .billingRetry(let expiration, let autoRenew): status = "billing_retry" isSubscribed = true hasFullAccess = true willAutoRenew = autoRenew isInGracePeriod = true trialDaysRemaining = nil expirationDate = expiration case .gracePeriod(let expiration, let autoRenew): status = "grace_period" isSubscribed = true hasFullAccess = true willAutoRenew = autoRenew isInGracePeriod = true trialDaysRemaining = nil expirationDate = expiration case .inTrial(let daysRemaining): status = "trial" isSubscribed = false hasFullAccess = true willAutoRenew = nil isInGracePeriod = nil trialDaysRemaining = daysRemaining expirationDate = nil case .trialExpired: status = "trial_expired" isSubscribed = false hasFullAccess = false willAutoRenew = nil isInGracePeriod = nil trialDaysRemaining = nil expirationDate = nil case .expired: status = "expired" isSubscribed = false hasFullAccess = false willAutoRenew = nil isInGracePeriod = nil trialDaysRemaining = nil expirationDate = nil case .revoked: status = "revoked" isSubscribed = false hasFullAccess = false willAutoRenew = nil isInGracePeriod = nil trialDaysRemaining = nil expirationDate = nil } AnalyticsManager.shared.trackSubscriptionStatusObserved( status: status, type: type, source: source, isSubscribed: isSubscribed, hasFullAccess: hasFullAccess, productId: productId, willAutoRenew: willAutoRenew, isInGracePeriod: isInGracePeriod, trialDaysRemaining: trialDaysRemaining, expirationDate: expirationDate ) } private func subscriptionType(for productID: String?) -> String { guard let productID else { return "none" } let id = productID.lowercased() if id.contains("annual") || id.contains("year") { return "yearly" } if id.contains("month") { return "monthly" } return "unknown" } private func listenForTransactions() -> Task { Task.detached { [weak self] in for await result in Transaction.updates { guard case .verified(let transaction) = result else { continue } await transaction.finish() await self?.checkSubscriptionStatus() } } } }