// // IAPManager.swift // Feels // // 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 inTrial(daysRemaining: Int) case trialExpired case expired } // 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) /// Set to `false` to test trial/subscription behavior in DEBUG builds #if DEBUG static let bypassSubscription = false #else static let bypassSubscription = false #endif // MARK: - Constants static let subscriptionGroupID = "21914363" private let productIdentifiers: Set = [ "com.88oakapps.feels.IAP.subscriptions.monthly", "com.88oakapps.feels.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 { if case .subscribed = state { return true } return false } var hasFullAccess: Bool { if Self.bypassSubscription { return true } switch state { case .subscribed, .inTrial: return true case .unknown, .trialExpired, .expired: return false } } var shouldShowPaywall: Bool { if Self.bypassSubscription { return false } switch state { case .trialExpired, .expired: return true case .unknown, .subscribed, .inTrial: return false } } var shouldShowTrialWarning: Bool { 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() { 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 syncSubscriptionStatusToUserDefaults() return } // No active subscription - check trial status updateTrialState() } /// Sync subscription status to UserDefaults for widget access private func syncSubscriptionStatusToUserDefaults() { let accessValue = Self.bypassSubscription ? true : hasFullAccess GroupUserDefaults.groupDefaults.set(accessValue, forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue) } /// Restore purchases func restore() async { do { try await AppStore.sync() await checkSubscriptionStatus() } catch { 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 { 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 { for status in statuses { guard case .verified(let renewalInfo) = status.renewalInfo else { continue } switch status.state { case .subscribed, .inGracePeriod, .inBillingRetryPeriod: state = .subscribed( expirationDate: transaction.expirationDate, willAutoRenew: renewalInfo.willAutoRenew ) return true case .expired, .revoked: continue default: continue } } } // Fallback if we couldn't get detailed status state = .subscribed(expirationDate: transaction.expirationDate, willAutoRenew: false) return true } // No active subscription found currentProduct = nil return false } 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() } 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() } } } }