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" private var productIds: [String] { var ids = [Self.monthlyProductId] if let bundleId = Bundle.main.bundleIdentifier { 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 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() await checkPremiumStatus() } catch { print("Restore failed: \(error)") } } private func checkPremiumStatus() async { isPremium = await Self.hasActiveSubscription(productIds: productIds) } var monthlyProduct: Product? { if let match = products.first(where: { product in product.id == Self.monthlyProductId && product.type == .autoRenewable }) { return match } if let match = products.first(where: { $0.id == Self.monthlyProductId }) { return match } if let match = products.first(where: { $0.id.contains("monthly") && $0.type == .autoRenewable }) { return match } if let match = products.first(where: { $0.type == .autoRenewable }) { return match } return products.first } var debugProductIds: [String] { productIds } static func hasActiveSubscription(productIds: [String] = [ StoreManager.monthlyProductId ]) async -> Bool { for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result, productIds.contains(transaction.productID), transaction.revocationDate == nil { 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) } }