SubscriptionManager.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. import AppKit
  2. import Combine
  3. import Foundation
  4. import StoreKit
  5. enum PurchaseError: Equatable {
  6. case productsLoadTimeout
  7. case subscriptionUnavailable
  8. case planUnavailable
  9. case purchaseNotVerified
  10. case purchasePending
  11. case noActiveSubscriptions
  12. case generic
  13. var message: String {
  14. switch self {
  15. case .productsLoadTimeout:
  16. "Couldn't load subscription products right now. Check your StoreKit configuration / internet and try again."
  17. case .subscriptionUnavailable:
  18. "This subscription is currently unavailable. Please try again in a moment."
  19. case .planUnavailable:
  20. "This plan isn't available right now. Please try again later."
  21. case .purchaseNotVerified:
  22. "Apple couldn't verify this purchase."
  23. case .purchasePending:
  24. "This purchase is waiting for approval (for example, Ask to Buy)."
  25. case .noActiveSubscriptions:
  26. "No active subscriptions were found for this Apple ID."
  27. case .generic:
  28. "Something went wrong with your subscription. Please try again."
  29. }
  30. }
  31. }
  32. @MainActor
  33. final class SubscriptionManager: ObservableObject {
  34. private enum ProductLoadError: Error {
  35. case timeout
  36. }
  37. @Published private(set) var productsByID: [String: Product] = [:]
  38. @Published private(set) var isLoadingProducts = false
  39. @Published private(set) var purchasingPlan: PaywallPlan?
  40. @Published private(set) var hasPremiumAccess = false
  41. @Published private(set) var hasResolvedPremiumStatus = false
  42. @Published var purchaseError: PurchaseError?
  43. var hasAllProductsLoaded: Bool {
  44. SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
  45. }
  46. private var inFlightProductLoad: Task<Void, Never>?
  47. init() {
  48. Task { await listenForTransactionUpdates() }
  49. Task { await refreshPremiumAccess() }
  50. Task { await loadProducts(presentLoadingUI: false) }
  51. }
  52. func loadProducts(presentLoadingUI: Bool? = nil) async {
  53. if let existing = inFlightProductLoad {
  54. await existing.value
  55. return
  56. }
  57. let uiFlag = presentLoadingUI
  58. let task = Task { @MainActor in
  59. await performLoadProducts(presentLoadingUI: uiFlag)
  60. }
  61. inFlightProductLoad = task
  62. await task.value
  63. inFlightProductLoad = nil
  64. }
  65. func product(for plan: PaywallPlan) -> Product? {
  66. productsByID[plan.productID]
  67. }
  68. func mainPrice(for plan: PaywallPlan) -> String {
  69. product(for: plan)?.displayPrice ?? plan.fallbackMainPrice
  70. }
  71. func priceSuffix(for plan: PaywallPlan) -> String? {
  72. switch plan {
  73. case .monthly: "/mo"
  74. case .yearly: "/yr"
  75. case .lifetime: nil
  76. }
  77. }
  78. func billingDescription(for plan: PaywallPlan) -> String {
  79. guard let product = product(for: plan) else {
  80. return plan.fallbackBillingDescription
  81. }
  82. if let subscription = product.subscription,
  83. let intro = subscription.introductoryOffer,
  84. intro.paymentMode == .freeTrial {
  85. let trialPeriod = formattedPeriod(intro.period)
  86. let billingPeriod = formattedSubscriptionPeriod(subscription.subscriptionPeriod)
  87. return "\(trialPeriod) Free Trial, then \(product.displayPrice) per \(billingPeriod)"
  88. }
  89. switch plan {
  90. case .monthly:
  91. return "Billed at \(product.displayPrice) every month"
  92. case .yearly:
  93. return "Billed at \(product.displayPrice) every year"
  94. case .lifetime:
  95. return "One-time payment of \(product.displayPrice)"
  96. }
  97. }
  98. func purchase(_ plan: PaywallPlan) async -> Bool {
  99. purchaseError = nil
  100. var product = productsByID[plan.productID]
  101. if product == nil {
  102. await loadProducts(presentLoadingUI: false)
  103. product = productsByID[plan.productID]
  104. }
  105. guard let product else {
  106. let missingProductIDs = SubscriptionProductID.all.filter { productsByID[$0] == nil }
  107. if missingProductIDs.isEmpty {
  108. purchaseError = .subscriptionUnavailable
  109. } else {
  110. purchaseError = .planUnavailable
  111. #if DEBUG
  112. print(
  113. """
  114. [SubscriptionManager] Missing StoreKit products: \(missingProductIDs.joined(separator: ", "))
  115. To test locally, attach `Paywall.storekit` in Xcode:
  116. Product > Scheme > Edit Scheme > Run > Options > StoreKit Configuration.
  117. """
  118. )
  119. #endif
  120. }
  121. return false
  122. }
  123. purchasingPlan = plan
  124. defer { purchasingPlan = nil }
  125. do {
  126. let result = try await product.purchase()
  127. switch result {
  128. case .success(let verification):
  129. guard case .verified(let transaction) = verification else {
  130. purchaseError = .purchaseNotVerified
  131. return false
  132. }
  133. await transaction.finish()
  134. await refreshPremiumAccess()
  135. return hasPremiumAccess
  136. case .userCancelled:
  137. return false
  138. case .pending:
  139. purchaseError = .purchasePending
  140. return false
  141. @unknown default:
  142. return false
  143. }
  144. } catch {
  145. purchaseError = .generic
  146. return false
  147. }
  148. }
  149. func openSubscriptionManagement() {
  150. guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
  151. NSWorkspace.shared.open(url)
  152. }
  153. func restorePurchases() async {
  154. purchaseError = nil
  155. do {
  156. try await AppStore.sync()
  157. await refreshPremiumAccess()
  158. if !hasPremiumAccess {
  159. purchaseError = .noActiveSubscriptions
  160. }
  161. } catch {
  162. purchaseError = .generic
  163. }
  164. }
  165. private func performLoadProducts(presentLoadingUI: Bool?) async {
  166. let missingAny = SubscriptionProductID.all.contains { productsByID[$0] == nil }
  167. let shouldShowLoading = presentLoadingUI ?? missingAny
  168. if !missingAny, presentLoadingUI != true {
  169. purchaseError = nil
  170. await refreshPremiumAccess()
  171. return
  172. }
  173. if shouldShowLoading {
  174. isLoadingProducts = true
  175. }
  176. defer {
  177. if shouldShowLoading {
  178. isLoadingProducts = false
  179. }
  180. }
  181. purchaseError = nil
  182. do {
  183. let loaded = try await loadProductsWithTimeout(seconds: 12)
  184. var map: [String: Product] = [:]
  185. for product in loaded {
  186. map[product.id] = product
  187. }
  188. productsByID = map
  189. await refreshPremiumAccess()
  190. } catch is ProductLoadError {
  191. purchaseError = .productsLoadTimeout
  192. } catch {
  193. purchaseError = .generic
  194. }
  195. }
  196. private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
  197. try await withThrowingTaskGroup(of: [Product].self) { group in
  198. group.addTask {
  199. try await Product.products(for: SubscriptionProductID.all)
  200. }
  201. group.addTask {
  202. let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
  203. try await Task.sleep(nanoseconds: timeoutNanoseconds)
  204. throw ProductLoadError.timeout
  205. }
  206. guard let first = try await group.next() else {
  207. throw ProductLoadError.timeout
  208. }
  209. group.cancelAll()
  210. return first
  211. }
  212. }
  213. private func refreshPremiumAccess() async {
  214. var hasPremium = false
  215. for await result in Transaction.currentEntitlements {
  216. guard case .verified(let transaction) = result else { continue }
  217. guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
  218. guard transaction.revocationDate == nil else { continue }
  219. if let expirationDate = transaction.expirationDate, expirationDate < Date() {
  220. continue
  221. }
  222. hasPremium = true
  223. break
  224. }
  225. if !hasPremium {
  226. hasPremium = await hasActiveSubscriptionStatus()
  227. }
  228. hasPremiumAccess = hasPremium
  229. hasResolvedPremiumStatus = true
  230. }
  231. private func hasActiveSubscriptionStatus() async -> Bool {
  232. if productsByID.isEmpty {
  233. do {
  234. let loaded = try await Product.products(for: SubscriptionProductID.all)
  235. var map: [String: Product] = [:]
  236. for product in loaded { map[product.id] = product }
  237. productsByID = map
  238. } catch {
  239. return false
  240. }
  241. }
  242. for product in productsByID.values {
  243. guard let subscription = product.subscription else { continue }
  244. do {
  245. let statuses = try await subscription.status
  246. let isActive = statuses.contains { status in
  247. switch status.state {
  248. case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
  249. return true
  250. case .expired, .revoked:
  251. return false
  252. default:
  253. return false
  254. }
  255. }
  256. if isActive { return true }
  257. } catch {
  258. continue
  259. }
  260. }
  261. return false
  262. }
  263. private func listenForTransactionUpdates() async {
  264. for await update in Transaction.updates {
  265. guard case .verified(let transaction) = update else { continue }
  266. await transaction.finish()
  267. await refreshPremiumAccess()
  268. }
  269. }
  270. private func formattedPeriod(_ period: Product.SubscriptionPeriod) -> String {
  271. switch period.unit {
  272. case .day where period.value == 1: "1 Day"
  273. case .day: "\(period.value) Days"
  274. case .week where period.value == 1: "1 Week"
  275. case .week: "\(period.value) Weeks"
  276. case .month where period.value == 1: "1 Month"
  277. case .month: "\(period.value) Months"
  278. case .year where period.value == 1: "1 Year"
  279. case .year: "\(period.value) Years"
  280. @unknown default: "\(period.value) Days"
  281. }
  282. }
  283. private func formattedSubscriptionPeriod(_ period: Product.SubscriptionPeriod) -> String {
  284. switch period.unit {
  285. case .day: period.value == 1 ? "day" : "\(period.value) days"
  286. case .week: period.value == 1 ? "week" : "\(period.value) weeks"
  287. case .month: period.value == 1 ? "month" : "\(period.value) months"
  288. case .year: period.value == 1 ? "year" : "\(period.value) years"
  289. @unknown default: "billing period"
  290. }
  291. }
  292. }