PremiumStore.swift 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import Combine
  2. import Foundation
  3. import StoreKit
  4. /// Product identifiers — create matching Non-Consumable + Auto-Renewable Subscription in App Store Connect.
  5. enum PremiumProductID: String, CaseIterable {
  6. case perpetual = "com.googleapps.lifetime"
  7. case yearly = "com.googleapps.yearly"
  8. }
  9. @MainActor
  10. final class PremiumStore: ObservableObject {
  11. static let shared = PremiumStore()
  12. @Published private(set) var products: [Product] = []
  13. @Published private(set) var isPremiumUnlocked = false
  14. @Published private(set) var activePremiumProductID: PremiumProductID?
  15. @Published private(set) var isLoadingProducts = false
  16. @Published private(set) var loadError: String?
  17. @Published private(set) var purchaseInProgress = false
  18. @Published var purchaseError: String?
  19. private var transactionListener: Task<Void, Never>?
  20. private init() {
  21. transactionListener = Task { await listenForTransactions() }
  22. Task { await refreshEntitlements() }
  23. }
  24. deinit {
  25. transactionListener?.cancel()
  26. }
  27. func product(for premiumID: PremiumProductID) -> Product? {
  28. products.first { $0.id == premiumID.rawValue }
  29. }
  30. func displayPrice(for premiumID: PremiumProductID) -> String? {
  31. product(for: premiumID)?.displayPrice
  32. }
  33. func loadProducts() async {
  34. isLoadingProducts = true
  35. loadError = nil
  36. defer { isLoadingProducts = false }
  37. do {
  38. let ids = PremiumProductID.allCases.map(\.rawValue)
  39. let loaded = try await Product.products(for: ids)
  40. products = loaded.sorted { $0.id < $1.id }
  41. if loaded.isEmpty {
  42. loadError = """
  43. No products returned for IDs: \(ids.joined(separator: ", ")).
  44. If testing locally, confirm Product > Scheme > Edit Scheme > Run > Options > StoreKit Configuration = Products.storekit, then stop/run again.
  45. """
  46. } else {
  47. let loadedIDs = Set(loaded.map(\.id))
  48. let missing = ids.filter { !loadedIDs.contains($0) }
  49. if !missing.isEmpty {
  50. loadError = "Store returned partial products. Missing IDs: \(missing.joined(separator: ", "))"
  51. }
  52. }
  53. } catch {
  54. let ids = PremiumProductID.allCases.map(\.rawValue).joined(separator: ", ")
  55. loadError = "StoreKit error: \(error.localizedDescription). Requested IDs: \(ids)"
  56. }
  57. }
  58. func purchase(_ product: Product) async -> Bool {
  59. purchaseInProgress = true
  60. purchaseError = nil
  61. defer { purchaseInProgress = false }
  62. do {
  63. let result = try await product.purchase()
  64. switch result {
  65. case .success(let verification):
  66. switch verification {
  67. case .verified(let transaction):
  68. if PremiumProductID(rawValue: transaction.productID) != nil {
  69. isPremiumUnlocked = true
  70. RatingPromptManager.shared.requestRatingIfNeeded()
  71. }
  72. await transaction.finish()
  73. await refreshEntitlements()
  74. return true
  75. case .unverified(_, let error):
  76. purchaseError = error.localizedDescription
  77. return false
  78. }
  79. case .userCancelled:
  80. return false
  81. case .pending:
  82. purchaseError = "Purchase is pending (e.g. Ask to Buy)."
  83. return false
  84. @unknown default:
  85. return false
  86. }
  87. } catch {
  88. purchaseError = error.localizedDescription
  89. return false
  90. }
  91. }
  92. func restorePurchases() async {
  93. loadError = nil
  94. do {
  95. try await AppStore.sync()
  96. await refreshEntitlements()
  97. } catch {
  98. loadError = error.localizedDescription
  99. }
  100. }
  101. func refreshEntitlements() async {
  102. let state = await resolvePremiumState()
  103. isPremiumUnlocked = state.isUnlocked
  104. activePremiumProductID = state.activeProductID
  105. }
  106. private func listenForTransactions() async {
  107. for await result in Transaction.updates {
  108. guard case .verified(let transaction) = result else { continue }
  109. await transaction.finish()
  110. await refreshEntitlements()
  111. }
  112. }
  113. private func resolvePremiumState() async -> (isUnlocked: Bool, activeProductID: PremiumProductID?) {
  114. let validIDs = Set(PremiumProductID.allCases.map(\.rawValue))
  115. var found: [PremiumProductID] = []
  116. for await result in Transaction.currentEntitlements {
  117. guard case .verified(let transaction) = result else { continue }
  118. guard validIDs.contains(transaction.productID) else { continue }
  119. if isTransactionActive(transaction) {
  120. if let productID = PremiumProductID(rawValue: transaction.productID) {
  121. found.append(productID)
  122. }
  123. }
  124. }
  125. if let preferred = preferredActiveProductID(from: found) {
  126. return (true, preferred)
  127. }
  128. // Fallback path for cases where `currentEntitlements` is briefly empty.
  129. found.removeAll()
  130. for id in PremiumProductID.allCases.map(\.rawValue) {
  131. guard let latestResult = await Transaction.latest(for: id) else { continue }
  132. guard case .verified(let transaction) = latestResult else { continue }
  133. if isTransactionActive(transaction) {
  134. if let productID = PremiumProductID(rawValue: transaction.productID) {
  135. found.append(productID)
  136. }
  137. }
  138. }
  139. if let preferred = preferredActiveProductID(from: found) {
  140. return (true, preferred)
  141. }
  142. return (false, nil)
  143. }
  144. private func isTransactionActive(_ transaction: Transaction) -> Bool {
  145. if transaction.revocationDate != nil {
  146. return false
  147. }
  148. if let expirationDate = transaction.expirationDate, expirationDate <= Date() {
  149. return false
  150. }
  151. return true
  152. }
  153. private func preferredActiveProductID(from ids: [PremiumProductID]) -> PremiumProductID? {
  154. if ids.contains(.perpetual) {
  155. return .perpetual
  156. }
  157. if ids.contains(.yearly) {
  158. return .yearly
  159. }
  160. return nil
  161. }
  162. }