// // IAPManager.swift // Feels // // Refactored StoreKit 2 subscription manager with clean state model. // import Foundation import StoreKit import SwiftUI // 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: - Constants static let subscriptionGroupID = "2CFE4C4F" private let productIdentifiers: Set = [ "com.tt.ifeel.IAP.subscriptions.monthly", "com.tt.ifeel.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 @AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date() // MARK: - Private private var updateListenerTask: Task? // MARK: - Computed Properties var isSubscribed: Bool { if case .subscribed = state { return true } return false } var hasFullAccess: Bool { switch state { case .subscribed, .inTrial: return true case .unknown, .trialExpired, .expired: return false } } var shouldShowPaywall: Bool { 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 func checkSubscriptionStatus() async { isLoading = true defer { isLoading = false } // 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() { GroupUserDefaults.groupDefaults.set(hasFullAccess, forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue) } /// Restore purchases func restore() async { do { try await AppStore.sync() await checkSubscriptionStatus() } catch { print("Failed to restore purchases: \(error)") } } // MARK: - Private Methods private func loadProducts() async { do { let products = try await Product.products(for: productIdentifiers) availableProducts = products.filter { $0.type == .autoRenewable } } catch { print("Failed to load products: \(error)") } } private func checkForActiveSubscription() async -> Bool { var foundActiveSubscription = false 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 } // Found an active subscription foundActiveSubscription = true // 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() } } } }