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.alexandrevazquez.mealmood.premium.monthly.sub" static let yearlyProductId = "com.alexandrevazquez.mealmood.premium.yearly.sub" static let legacyOneTimeProductId = "com.mealmood.premium.monthly" private var productIds: [String] { var ids = [Self.monthlyProductId, Self.yearlyProductId, Self.legacyOneTimeProductId] if let bundleId = Bundle.main.bundleIdentifier { ids.append("\(bundleId).premium.monthly.sub") ids.append("\(bundleId).premium.yearly.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 CrashlyticsService.record(error, context: "load_products") } 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(environment: Self.environmentName(for: transaction)) return .success } AnalyticsService.logPremiumPurchaseFailed(reason: "unverified", productId: product.id) return .failed case .userCancelled: AnalyticsService.logPremiumPurchaseFailed(reason: "cancelled", productId: product.id) return .cancelled case .pending: AnalyticsService.logPremiumPurchaseFailed(reason: "pending", productId: product.id) return .pending @unknown default: AnalyticsService.logPremiumPurchaseFailed(reason: "unknown", productId: product.id) return .failed } } catch { CrashlyticsService.record(error, context: "purchase") AnalyticsService.logPremiumPurchaseFailed( reason: "error", productId: product.id, detail: error.localizedDescription ) 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 { CrashlyticsService.record(error, context: "restore") 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 && $0.subscription?.subscriptionPeriod.unit == .month }) } var yearlyProduct: Product? { if let match = products.first(where: { $0.id == Self.yearlyProductId && $0.type == .autoRenewable }) { return match } return products.first(where: { $0.type == .autoRenewable && $0.subscription?.subscriptionPeriod.unit == .year }) } var lifetimeProduct: Product? { products.first(where: { $0.type == .nonConsumable }) } var yearlySavingsPercent: Int? { guard let monthly = monthlyProduct, let yearly = yearlyProduct else { return nil } let monthlyAnnualised = monthly.price * 12 guard monthlyAnnualised > 0 else { return nil } let savings = (monthlyAnnualised - yearly.price) / monthlyAnnualised let percent = Int((NSDecimalNumber(decimal: savings).doubleValue * 100).rounded()) return percent > 0 ? percent : nil } var hasTrialAvailable: Bool { guard let yearly = yearlyProduct, let intro = yearly.subscription?.introductoryOffer else { return false } return intro.paymentMode == .freeTrial } var debugProductIds: [String] { productIds } static func hasActiveSubscription() async -> Bool { let bundleId = Bundle.main.bundleIdentifier ?? "" let subscriptionIds: Set = [ monthlyProductId, yearlyProductId, "\(bundleId).premium.monthly.sub", "\(bundleId).premium.yearly.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 } /// Maps a transaction's StoreKit environment to an analytics-friendly string /// so sandbox/Xcode test purchases can be excluded from real conversion metrics. static func environmentName(for transaction: StoreKit.Transaction) -> String { switch transaction.environment { case .production: return "production" case .sandbox: return "sandbox" case .xcode: return "xcode" default: return "unknown" } } 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) } }