// // 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 @Published var bypassSubscription: Bool { didSet { UserDefaults.standard.set(bypassSubscription, forKey: "debug_bypassSubscription") } } // MARK: - Constants static let subscriptionGroupID = "21951685" static 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() { self.bypassSubscription = UserDefaults.standard.bool(forKey: "debug_bypassSubscription") 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 /// Pass `force: true` to bypass throttle (e.g. after a transaction update) func checkSubscriptionStatus(force: Bool = false) async { AppLogger.iap.debug("checkSubscriptionStatus: called (force=\(force), currentState=\(String(describing: self.state)))") // Throttle: skip if we checked recently (unless state is unknown or forced) if !force, state != .unknown, let lastCheck = lastStatusCheckTime, Date().timeIntervalSince(lastCheck) < statusCheckInterval { AppLogger.iap.debug("checkSubscriptionStatus: THROTTLED (last check \(Date().timeIntervalSince(lastCheck))s ago)") return } // Only update isLoading if value actually changes to avoid unnecessary view updates if !isLoading { isLoading = true } defer { if isLoading { isLoading = false } lastStatusCheckTime = Date() AppLogger.iap.debug("checkSubscriptionStatus: DONE — final state=\(String(describing: self.state))") } // Fetch available products await loadProducts() // Check for active subscription let hasActiveSubscription = await checkForActiveSubscription() AppLogger.iap.debug("checkSubscriptionStatus: hasActiveSubscription=\(hasActiveSubscription), state after check=\(String(describing: self.state))") 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 } // Live check found no active subscription. // Only trust cached expiration if we're offline (products failed to load). // If products loaded successfully, StoreKit had server access and the live result is authoritative. if availableProducts.isEmpty { let cachedExpiration = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue) as? Date AppLogger.iap.debug("checkSubscriptionStatus: offline (no products) — cachedExpiration=\(cachedExpiration?.description ?? "nil")") if let expiration = cachedExpiration, expiration > Date() { AppLogger.iap.debug("checkSubscriptionStatus: using cached expiration (offline, still valid)") state = .subscribed(expirationDate: expiration, willAutoRenew: false) syncSubscriptionStatusToUserDefaults() trackSubscriptionAnalytics(source: "status_check_cached") return } } // Subscription genuinely gone — clear cache and fall back to trial AppLogger.iap.debug("checkSubscriptionStatus: falling back to trial — firstLaunchDate=\(self.firstLaunchDate), trialDays=\(self.trialDays)") 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) let cachedExpiration = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.cachedSubscriptionExpiration.rawValue) as? Date AppLogger.iap.debug("restoreCachedSubscriptionState: hasActive=\(hasActive), cachedExpiration=\(cachedExpiration?.description ?? "nil")") guard hasActive else { AppLogger.iap.debug("restoreCachedSubscriptionState: no cached active state, skipping") return } // If we have a cached expiration and it's still in the future, restore subscribed state if let expiration = cachedExpiration, expiration > Date() { AppLogger.iap.debug("restoreCachedSubscriptionState: restoring .subscribed (cached expiration in future)") 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 AppLogger.iap.debug("restoreCachedSubscriptionState: restoring .subscribed (no expiration, trusting hasActive)") state = .subscribed(expirationDate: nil, willAutoRenew: false) } else { AppLogger.iap.debug("restoreCachedSubscriptionState: cached expiration in past, not restoring") } } /// Restore purchases func restore(source: String = "settings") async { do { try await AppStore.sync() await checkSubscriptionStatus(force: true) 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 { AppLogger.iap.info("loadProducts() called — requesting: \(Self.productIdentifiers.sorted().joined(separator: ", "))") do { let products = try await Product.products(for: Self.productIdentifiers) AppLogger.iap.info("loadProducts() returned \(products.count) products total") for product in products { AppLogger.iap.info(" Product: \(product.id) — type: \(product.type.rawValue), displayName: \(product.displayName), price: \(product.displayPrice)") } availableProducts = products.filter { $0.type == .autoRenewable } AppLogger.iap.info("loadProducts() filtered to \(self.availableProducts.count) autoRenewable products") } catch { AppLogger.iap.error("loadProducts() FAILED: \(error.localizedDescription) — full error: \(String(describing: error))") } } 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 Self.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: [Product.SubscriptionInfo.Status] do { statuses = try await subscription.status } catch { AppLogger.iap.error("Failed to fetch subscription status for \(product.id): \(error)") // Fallback handled below state = .subscribed(expirationDate: transaction.expirationDate, willAutoRenew: false) return true } 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 } /// 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() } private func updateTrialState() { let daysSinceInstall = Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0 let daysRemaining = trialDays - daysSinceInstall AppLogger.iap.debug("updateTrialState: firstLaunchDate=\(self.firstLaunchDate), daysSinceInstall=\(daysSinceInstall), daysRemaining=\(daysRemaining)") if daysRemaining > 0 { AppLogger.iap.debug("updateTrialState: setting .inTrial(daysRemaining: \(daysRemaining))") state = .inTrial(daysRemaining: daysRemaining) } else { AppLogger.iap.debug("updateTrialState: setting .trialExpired") 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(force: true) } } } }