// // SubscriptionStore.swift // App for Indeed // import Foundation import StoreKit extension Notification.Name { static let subscriptionStatusDidChange = Notification.Name("subscriptionStatusDidChange") } @MainActor final class SubscriptionStore { static let shared = SubscriptionStore() private(set) var productsByID: [String: Product] = [:] /// Mirrors StoreKit entitlements; safe to read on the main thread after `refreshEntitlements()` or a `.subscriptionStatusDidChange` notification. private(set) var isProActive: Bool = false private var transactionListenerTask: Task? private init() { transactionListenerTask = Task { await listenForTransactions() } } deinit { transactionListenerTask?.cancel() } /// Syncs `isProActive` with StoreKit. Set `deep` to also consult subscription status via `Product.products` when entitlements are empty (after sync, restore, or opening the paywall). /// Posts `.subscriptionStatusDidChange` when the value changes. func refreshEntitlements(deep: Bool = false) async { let before = isProActive isProActive = await computeProEntitlementFromStore() if !isProActive, deep { isProActive = await computeProFromLoadedSubscriptionProducts() } if before != isProActive { NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil) } } /// Loads subscription products from the App Store (or StoreKit Testing in Xcode). func loadProducts() async { do { let products = try await Product.products(for: SubscriptionProductIDs.all) var map: [String: Product] = [:] for product in products { map[product.id] = product } productsByID = map } catch { productsByID = [:] } } func product(forPlanKey planKey: String) -> Product? { guard let id = SubscriptionProductIDs.productID(planKey: planKey) else { return nil } return productsByID[id] } func purchase(planKey: String) async throws -> Bool { guard let product = product(forPlanKey: planKey) else { throw SubscriptionStoreError.productUnavailable } let result = try await product.purchase() switch result { case .success(let verification): let transaction = try checkVerified(verification) await transaction.finish() await refreshEntitlements(deep: true) return true case .userCancelled: return false case .pending: return false @unknown default: return false } } /// Restores purchases by syncing with the App Store (required on macOS instead of `restoreCompletedTransactions`). func restorePurchases() async throws { try await AppStore.sync() await refreshEntitlements(deep: true) } /// Whether the user has an active subscription for one of this app’s Pro product IDs. func hasActiveSubscription() async -> Bool { await refreshEntitlements(deep: true) return isProActive } private func computeProEntitlementFromStore() async -> Bool { for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { continue } if transaction.revocationDate != nil { continue } if SubscriptionProductIDs.all.contains(transaction.productID) { return true } } return false } /// Fallback when `currentEntitlements` is empty or lagging (common right after install, account changes, or macOS StoreKit edge cases). private func computeProFromLoadedSubscriptionProducts() async -> Bool { do { let products = try await Product.products(for: SubscriptionProductIDs.all) for product in products { guard let subscription = product.subscription else { continue } let statuses = try await subscription.status for status in statuses { switch status.state { case .subscribed, .inGracePeriod, .inBillingRetryPeriod: return true case .expired, .revoked: break default: break } } } } catch { return false } return false } private func listenForTransactions() async { for await result in Transaction.updates { guard case .verified(let transaction) = result else { continue } await transaction.finish() await refreshEntitlements(deep: true) } } private nonisolated func checkVerified(_ result: VerificationResult) throws -> Transaction { switch result { case .unverified(_, let error): throw error case .verified(let transaction): return transaction } } } enum SubscriptionStoreError: LocalizedError { /// No `Product` was returned for this ID (wrong IDs, products not approved in ASC, or StoreKit Configuration not selected in the scheme). case productUnavailable var errorDescription: String? { switch self { case .productUnavailable: return "That subscription isn’t available from the App Store right now." } } var recoverySuggestion: String? { switch self { case .productUnavailable: return """ For local testing in Xcode: Product → Scheme → Edit Scheme → Run → Options → StoreKit Configuration → choose Paywall.storekit. For TestFlight / App Store: In App Store Connect, create auto-renewable subscriptions whose Product IDs exactly match SubscriptionProductIDs.swift (same spelling as com.hwaccount.appforindeed.pro.*), then submit them with the app version. """ } } }