import StoreKit import SwiftUI @MainActor final class StoreManager: ObservableObject { enum ProductLoadState { case idle case loading case loaded case timedOut case notFound case failed } enum PurchaseResult { case success case cancelled case pending case failed } @Published var products: [Product] = [] @Published var isPremium: Bool = false @Published var isLoading: Bool = false @Published var isLoadingProducts: Bool = false @Published var productLoadState: ProductLoadState = .idle @Published var debugLoadedProductIds: [String] = [] @Published var debugLoadedProducts: [String] = [] static let monthlyProductId = "com.mealmood.premium.monthly.sub" static let legacyOneTimeProductId = "com.mealmood.premium.monthly" private var productIds: [String] { var ids = [Self.monthlyProductId, Self.legacyOneTimeProductId] if let bundleId = Bundle.main.bundleIdentifier { ids.append("\(bundleId).premium.monthly.sub") ids.append("\(bundleId).premium.monthly") } var seen = Set() return ids.filter { seen.insert($0).inserted } } init() { Task { await loadProducts() await checkPremiumStatus() } } func loadProducts() async { isLoadingProducts = true productLoadState = .loading defer { isLoadingProducts = false } do { let fetchedProducts = try await loadProductsWithRetries() products = fetchedProducts debugLoadedProductIds = fetchedProducts.map(\.id) debugLoadedProducts = fetchedProducts.map { "\($0.id) [\($0.type)]" } productLoadState = fetchedProducts.isEmpty ? .notFound : .loaded } catch { if error is TimeoutError { productLoadState = .timedOut } else { productLoadState = .failed } print("Failed to load products: \(error)") } } func purchase(_ product: Product) async -> PurchaseResult { isLoading = true defer { isLoading = false } do { let result = try await product.purchase() switch result { case .success(let verification): if case .verified(let transaction) = verification { await transaction.finish() isPremium = true AnalyticsService.logPremiumPurchased() return .success } return .failed case .userCancelled: return .cancelled case .pending: return .pending @unknown default: return .failed } } catch { print("Purchase failed: \(error)") return .failed } } func restorePurchases() async { isLoading = true defer { isLoading = false } do { try await AppStore.sync() let wasNotPremium = !isPremium await checkPremiumStatus() if wasNotPremium && isPremium { AnalyticsService.logPremiumRestored() } } catch { print("Restore failed: \(error)") } } private func checkPremiumStatus() async { isPremium = await Self.hasActiveSubscription() } var monthlyProduct: Product? { if let match = products.first(where: { $0.id == Self.monthlyProductId && $0.type == .autoRenewable }) { return match } return products.first(where: { $0.type == .autoRenewable }) } var debugProductIds: [String] { productIds } static func hasActiveSubscription() async -> Bool { let bundleId = Bundle.main.bundleIdentifier ?? "" let subscriptionIds: Set = [monthlyProductId, "\(bundleId).premium.monthly.sub"] // Legacy one-time purchases grant lifetime access — no expiration check needed. let legacyIds: Set = [legacyOneTimeProductId, "\(bundleId).premium.monthly"] for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result, transaction.revocationDate == nil else { continue } if legacyIds.contains(transaction.productID) { return true } if subscriptionIds.contains(transaction.productID) { if let expirationDate = transaction.expirationDate, expirationDate < Date() { continue } return true } } return false } private struct TimeoutError: Error {} private func loadProductsWithRetries(maxAttempts: Int = 3) async throws -> [Product] { var lastProducts: [Product] = [] for attempt in 1...maxAttempts { let products = try await loadProductsWithTimeout(seconds: 12) if !products.isEmpty { return products } lastProducts = products if attempt < maxAttempts { try await Task.sleep(nanoseconds: 800_000_000) } } return lastProducts } private func loadProductsWithTimeout(seconds: UInt64) async throws -> [Product] { let ids = productIds return try await withThrowingTaskGroup(of: [Product].self) { group in group.addTask { try await self.loadProductsBySingleID(ids: ids) } group.addTask { try await Task.sleep(nanoseconds: seconds * 1_000_000_000) throw TimeoutError() } let firstResult = try await group.next() ?? [] group.cancelAll() return firstResult } } private func loadProductsBySingleID(ids: [String]) async throws -> [Product] { var merged: [String: Product] = [:] // First, attempt a single batch request with all IDs. let batchItems = try await Product.products(for: ids) for item in batchItems { merged[item.id] = item } if !merged.isEmpty { return Array(merged.values) } // Fallback to one-by-one requests for better resilience/debugging. for id in ids { let items = try await Product.products(for: [id]) for item in items { merged[item.id] = item } } return Array(merged.values) } }