import Foundation import StoreKit import ComposeApp /// StoreKit 2 manager for in-app purchases /// Handles product loading, purchases, transaction observation, and backend verification @MainActor class StoreKitManager: ObservableObject { static let shared = StoreKitManager() // Product IDs can be configured via Info.plist keys: // HONEYDUE_IAP_MONTHLY_PRODUCT_ID / HONEYDUE_IAP_ANNUAL_PRODUCT_ID. // Falls back to local StoreKit config IDs for development. // Canonical source: SubscriptionProducts in commonMain (Kotlin shared code). // Keep these in sync with SubscriptionProducts.MONTHLY / SubscriptionProducts.ANNUAL. private let fallbackProductIDs = [ "com.myhoneydue.honeyDue.pro.monthly", "com.myhoneydue.honeyDue.pro.annual" ] private var configuredProductIDs: [String] { let monthly = Bundle.main.object(forInfoDictionaryKey: "HONEYDUE_IAP_MONTHLY_PRODUCT_ID") as? String let annual = Bundle.main.object(forInfoDictionaryKey: "HONEYDUE_IAP_ANNUAL_PRODUCT_ID") as? String return [monthly, annual] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } } private var productIDs: [String] { if configuredProductIDs.isEmpty { return fallbackProductIDs } return configuredProductIDs } @Published var products: [Product] = [] @Published var purchasedProductIDs: Set = [] @Published var isLoading = false @Published var purchaseError: String? private var transactionListener: Task? private init() { // Start listening for transactions transactionListener = listenForTransactions() // Check for existing entitlements Task { await updatePurchasedProducts() } } deinit { transactionListener?.cancel() } /// Load available products from App Store func loadProducts() async { isLoading = true defer { isLoading = false } do { if configuredProductIDs.isEmpty { print("⚠️ StoreKit: Using fallback product IDs (configure HONEYDUE_IAP_MONTHLY_PRODUCT_ID/HONEYDUE_IAP_ANNUAL_PRODUCT_ID in Info.plist for production)") } let loadedProducts = try await Product.products(for: productIDs) products = loadedProducts.sorted { $0.price < $1.price } print("✅ StoreKit: Loaded \(products.count) products") } catch { print("❌ StoreKit: Failed to load products: \(error)") purchaseError = "Failed to load products: \(error.localizedDescription)" } } /// Purchase a product func purchase(_ product: Product) async throws -> Transaction? { print("🛒 StoreKit: Initiating purchase for \(product.id)") // Start the purchase let result = try await product.purchase() switch result { case .success(let verification): // Verify the transaction let transaction = try checkVerified(verification) // Update purchased products await updatePurchasedProducts() // Verify with backend — only finish the transaction if verification succeeds do { try await verifyTransactionWithBackend(transaction) await transaction.finish() print("✅ StoreKit: Purchase successful for \(product.id)") } catch { print("⚠️ StoreKit: Backend verification failed for \(product.id), transaction NOT finished so it can be retried: \(error)") self.purchaseError = "Purchase successful but verification is pending. It will complete automatically." } return transaction case .userCancelled: print("⚠️ StoreKit: User cancelled purchase") return nil case .pending: print("⏳ StoreKit: Purchase pending (requires approval)") return nil @unknown default: print("❌ StoreKit: Unknown purchase result") return nil } } /// Restore previous purchases func restorePurchases() async { print("🔄 StoreKit: Restoring purchases") do { // Sync with App Store try await AppStore.sync() // Update purchased products await updatePurchasedProducts() // Verify all current entitlements with backend for await result in Transaction.currentEntitlements { let transaction = try checkVerified(result) try await verifyTransactionWithBackend(transaction) } print("✅ StoreKit: Purchases restored") } catch { print("❌ StoreKit: Failed to restore purchases: \(error)") purchaseError = "Failed to restore purchases: \(error.localizedDescription)" } } /// Check if user has an active subscription func hasActiveSubscription() async -> Bool { // Check current entitlements for await result in Transaction.currentEntitlements { if let transaction = try? checkVerified(result), transaction.productType == .autoRenewable, !transaction.isUpgraded { return true } } return false } /// Refresh subscription status from StoreKit /// Call this when app comes to foreground to ensure we have latest status func refreshSubscriptionStatus() async { await updatePurchasedProducts() print("🔄 StoreKit: Subscription status refreshed") } /// Verify current entitlements with backend on app launch /// This ensures the backend has up-to-date subscription info func verifyEntitlementsOnLaunch() async { print("🔄 StoreKit: Verifying entitlements on launch...") // Get current entitlements from StoreKit for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { continue } // Skip if revoked if transaction.revocationDate != nil { continue } // Only process subscription products if transaction.productType == .autoRenewable { print("📦 StoreKit: Found active subscription: \(transaction.productID)") // Verify this transaction with backend (best-effort on launch) do { try await verifyTransactionWithBackend(transaction) } catch { print("⚠️ StoreKit: Backend verification failed on launch for \(transaction.productID): \(error)") } // Update local purchased products _ = purchasedProductIDs.insert(transaction.productID) } } // After verifying all entitlements, refresh subscription status from backend await refreshSubscriptionFromBackend() print("✅ StoreKit: Launch verification complete") } /// Fetch latest subscription status from backend and update cache private func refreshSubscriptionFromBackend() async { do { let statusResult = try await APILayer.shared.getSubscriptionStatus(forceRefresh: true) if let statusSuccess = statusResult as? ApiResultSuccess, let subscription = statusSuccess.data { SubscriptionCacheWrapper.shared.updateSubscription(subscription) print("✅ StoreKit: Backend subscription status updated - Tier: \(subscription.limits)") } } catch { print("❌ StoreKit: Failed to refresh subscription from backend: \(error)") } } /// Update purchased product IDs private func updatePurchasedProducts() async { var purchasedIDs: Set = [] // Iterate through all current entitlements for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { continue } // Add to purchased set if not revoked or expired if transaction.revocationDate == nil { purchasedIDs.insert(transaction.productID) } } purchasedProductIDs = purchasedIDs print("📦 StoreKit: Updated purchased products: \(purchasedIDs)") } /// Listen for transaction updates private func listenForTransactions() -> Task { return Task { for await result in Transaction.updates { do { let transaction = try checkVerified(result) await updatePurchasedProducts() do { try await verifyTransactionWithBackend(transaction) await transaction.finish() print("✅ StoreKit: Transaction updated: \(transaction.productID)") } catch { print("⚠️ StoreKit: Backend verification failed for \(transaction.productID), transaction NOT finished so it can be retried: \(error)") } } catch { print("❌ StoreKit: Transaction verification failed: \(error)") } } } } /// Verify transaction with backend API /// Throws if backend verification fails so callers can decide whether to finish the transaction private func verifyTransactionWithBackend(_ transaction: Transaction) async throws { let receiptData = String(transaction.id) // Call backend verification endpoint via APILayer let result = try await APILayer.shared.verifyIOSReceipt( receiptData: receiptData, transactionId: String(transaction.id) ) // Handle result (Kotlin ApiResult type) if let successResult = result as? ApiResultSuccess, let response = successResult.data, response.success { print("✅ StoreKit: Backend verification successful - Tier: \(response.tier ?? "unknown")") // Fetch updated subscription status from backend via APILayer let statusResult = try await APILayer.shared.getSubscriptionStatus(forceRefresh: true) if let statusSuccess = statusResult as? ApiResultSuccess, let subscription = statusSuccess.data { SubscriptionCacheWrapper.shared.updateSubscription(subscription) } } else if let errorResult = ApiResultBridge.error(from: result) { let message = errorResult.message print("❌ StoreKit: Backend verification failed: \(message)") throw StoreKitError.backendVerificationFailed(message) } else if let successResult = result as? ApiResultSuccess, let response = successResult.data, !response.success { let message = response.message print("❌ StoreKit: Backend verification failed: \(message)") throw StoreKitError.backendVerificationFailed(message) } } /// Verify the cryptographic signature of a transaction private func checkVerified(_ result: VerificationResult) throws -> T { switch result { case .unverified: throw StoreKitError.verificationFailed case .verified(let safe): return safe } } } // MARK: - StoreKit Errors extension StoreKitManager { enum StoreKitError: LocalizedError { case verificationFailed case noProducts case purchaseFailed case backendVerificationFailed(String) var errorDescription: String? { switch self { case .verificationFailed: return "Transaction verification failed" case .noProducts: return "No products available" case .purchaseFailed: return "Purchase failed" case .backendVerificationFailed(let message): return "Backend verification failed: \(message)" } } } }