SubscriptionManager.swift 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  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 noActivePurchases
  13. case restoreSyncTimeout
  14. case generic
  15. }
  16. @MainActor
  17. final class SubscriptionManager: ObservableObject {
  18. private enum ProductLoadError: Error {
  19. case timeout
  20. }
  21. private enum StoreSyncError: Error {
  22. case timeout
  23. }
  24. private static let cachedPremiumKey = "SubscriptionManager.cachedIsPremium"
  25. private static let cachedPremiumPlanKey = "SubscriptionManager.cachedPremiumPlan"
  26. private static let cachedHasEverPurchasedKey = "SubscriptionManager.cachedHasEverPurchased"
  27. @Published private(set) var productsByID: [String: Product] = [:]
  28. @Published private(set) var isLoadingProducts = false
  29. @Published private(set) var purchasingPlan: PaywallPlan?
  30. @Published private(set) var isRestoringPurchases = false
  31. @Published private(set) var hasPremiumAccess = false
  32. @Published private(set) var hasResolvedPremiumStatus = false
  33. @Published private(set) var activePremiumPlan: PaywallPlan?
  34. @Published private(set) var productLoadError: String?
  35. @Published private(set) var hasEverPurchasedPremium = false
  36. @Published private(set) var trialDisplayByPlan: [PaywallPlan: PaywallTrialDisplay] = [:]
  37. @Published var purchaseError: PurchaseError?
  38. var availablePaywallPlans: [PaywallPlan] {
  39. PaywallPlan.allCases
  40. }
  41. var isResolvingEntitlements: Bool {
  42. !hasResolvedPremiumStatus
  43. }
  44. var hasLifetimeAccess: Bool {
  45. activePremiumPlan == .lifetime
  46. }
  47. var hasActiveSubscription: Bool {
  48. activePremiumPlan == .monthly || activePremiumPlan == .yearly
  49. }
  50. var hasAllProductsLoaded: Bool {
  51. SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
  52. }
  53. var isEligibleForFreeAITier: Bool {
  54. !hasEverPurchasedPremium
  55. }
  56. var isEligibleForIntroTrial: Bool {
  57. !hasEverPurchasedPremium && !hasPremiumAccess
  58. }
  59. private var inFlightProductLoad: Task<Void, Never>?
  60. private var initialEntitlementTask: Task<Void, Never>?
  61. private var entitlementRefreshTask: Task<Void, Never>?
  62. private var hasStarted = false
  63. private var launchedWithCachedPremium = false
  64. private var cancellables = Set<AnyCancellable>()
  65. private var lastBackgroundEntitlementRefresh: Date?
  66. /// Minimum time between entitlement re-checks when the app regains focus.
  67. private static let backgroundRefreshInterval: TimeInterval = 120
  68. init() {
  69. hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: Self.cachedHasEverPurchasedKey)
  70. // Optimistic restore so returning subscribers aren't paywalled while StoreKit resolves.
  71. if UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) {
  72. launchedWithCachedPremium = true
  73. hasPremiumAccess = true
  74. if let planRaw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
  75. let plan = PaywallPlan(rawValue: planRaw) {
  76. activePremiumPlan = plan
  77. }
  78. }
  79. }
  80. func start() {
  81. guard !hasStarted else { return }
  82. hasStarted = true
  83. observeAppLifecycle()
  84. Task { await listenForTransactionUpdates() }
  85. initialEntitlementTask = Task {
  86. // Local StoreKit entitlements only on launch. AppStore.sync() prompts for
  87. // Apple ID and fires on every relaunch when purchase flags are cached.
  88. await refreshPremiumAccess()
  89. if launchedWithCachedPremium {
  90. launchedWithCachedPremium = false
  91. hasResolvedPremiumStatus = true
  92. persistPremiumCache()
  93. }
  94. await loadProducts(presentLoadingUI: false)
  95. }
  96. }
  97. private func observeAppLifecycle() {
  98. NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)
  99. .sink { [weak self] _ in
  100. self?.refreshEntitlementsOnAppActive()
  101. }
  102. .store(in: &cancellables)
  103. }
  104. private func refreshEntitlementsOnAppActive() {
  105. guard hasStarted else { return }
  106. if let lastRefresh = lastBackgroundEntitlementRefresh,
  107. Date().timeIntervalSince(lastRefresh) < Self.backgroundRefreshInterval {
  108. return
  109. }
  110. entitlementRefreshTask?.cancel()
  111. entitlementRefreshTask = Task {
  112. // Local StoreKit entitlements only — AppStore.sync() prompts for Apple ID
  113. // and can loop in Xcode when focus returns after dismissing the dialog.
  114. await refreshPremiumAccess()
  115. lastBackgroundEntitlementRefresh = Date()
  116. }
  117. }
  118. func ensureEntitlementsResolved() async {
  119. await initialEntitlementTask?.value
  120. }
  121. func loadProducts(presentLoadingUI: Bool? = nil) async {
  122. if let existing = inFlightProductLoad {
  123. await existing.value
  124. return
  125. }
  126. let uiFlag = presentLoadingUI
  127. let task = Task { @MainActor in
  128. await performLoadProducts(presentLoadingUI: uiFlag)
  129. }
  130. inFlightProductLoad = task
  131. await task.value
  132. inFlightProductLoad = nil
  133. }
  134. func product(for plan: PaywallPlan) -> Product? {
  135. productsByID[plan.productID]
  136. }
  137. func trialDisplay(for plan: PaywallPlan) -> PaywallTrialDisplay? {
  138. trialDisplayByPlan[plan]
  139. }
  140. func refreshTrialEligibility() async {
  141. await refreshTrialOffers()
  142. }
  143. func mainPrice(for plan: PaywallPlan) -> String {
  144. let config = PaywallConfigService.shared.config
  145. return product(for: plan)?.displayPrice ?? config.loadingPrice
  146. }
  147. func priceSuffix(for plan: PaywallPlan) -> String? {
  148. plan.planCopy(from: PaywallConfigService.shared.config).priceSuffix
  149. }
  150. func billingDescription(for plan: PaywallPlan) -> String {
  151. let config = PaywallConfigService.shared.config
  152. guard let product = product(for: plan) else {
  153. return config.loadingDisclosure
  154. }
  155. switch plan {
  156. case .monthly:
  157. return "Billed at \(product.displayPrice) every month"
  158. case .yearly:
  159. return "Billed at \(product.displayPrice) every year"
  160. case .lifetime:
  161. return "One-time payment of \(product.displayPrice)"
  162. }
  163. }
  164. func purchase(_ plan: PaywallPlan) async -> Bool {
  165. purchaseError = nil
  166. guard availablePaywallPlans.contains(plan) else {
  167. purchaseError = .planUnavailable
  168. return false
  169. }
  170. var product = productsByID[plan.productID]
  171. if product == nil {
  172. await loadProducts(presentLoadingUI: false)
  173. product = productsByID[plan.productID]
  174. }
  175. guard let product else {
  176. let missingProductIDs = SubscriptionProductID.all.filter { productsByID[$0] == nil }
  177. if missingProductIDs.isEmpty {
  178. purchaseError = .subscriptionUnavailable
  179. } else {
  180. purchaseError = .planUnavailable
  181. #if DEBUG
  182. print(
  183. """
  184. [SubscriptionManager] Missing StoreKit products: \(missingProductIDs.joined(separator: ", "))
  185. To test locally, attach `Paywall.storekit` in Xcode:
  186. Product > Scheme > Edit Scheme > Run > Options > StoreKit Configuration.
  187. """
  188. )
  189. #endif
  190. }
  191. return false
  192. }
  193. purchasingPlan = plan
  194. defer { purchasingPlan = nil }
  195. do {
  196. let result = try await product.purchase()
  197. switch result {
  198. case .success(let verification):
  199. guard case .verified(let transaction) = verification else {
  200. purchaseError = .purchaseNotVerified
  201. return false
  202. }
  203. let trustedPlan = planGrantingPremium(from: transaction)
  204. if let trustedPlan {
  205. applyPremiumAccess(from: transaction)
  206. }
  207. await transaction.finish()
  208. await reconcilePremiumAccess(trustedPlan: trustedPlan)
  209. return hasPremiumAccess
  210. case .userCancelled:
  211. return false
  212. case .pending:
  213. purchaseError = .purchasePending
  214. return false
  215. @unknown default:
  216. return false
  217. }
  218. } catch {
  219. purchaseError = .generic
  220. return false
  221. }
  222. }
  223. func openSubscriptionManagement() {
  224. let urlString = PaywallConfigService.shared.config.urls.manageSubscriptions
  225. guard let url = URL(string: urlString) else { return }
  226. NSWorkspace.shared.open(url)
  227. }
  228. func restorePurchases() async {
  229. purchaseError = nil
  230. isRestoringPurchases = true
  231. defer { isRestoringPurchases = false }
  232. do {
  233. try await syncAppStoreWithTimeout(seconds: 30)
  234. } catch is StoreSyncError {
  235. purchaseError = .restoreSyncTimeout
  236. return
  237. } catch {
  238. purchaseError = .generic
  239. return
  240. }
  241. await reconcilePremiumAccess(trustedPlan: nil)
  242. if !hasPremiumAccess {
  243. purchaseError = .noActivePurchases
  244. }
  245. }
  246. private func performLoadProducts(presentLoadingUI: Bool?) async {
  247. let missingAny = SubscriptionProductID.all.contains { productsByID[$0] == nil }
  248. let shouldShowLoading = presentLoadingUI ?? missingAny
  249. if !missingAny, presentLoadingUI != true {
  250. purchaseError = nil
  251. await refreshPremiumAccess()
  252. await refreshTrialOffers()
  253. return
  254. }
  255. if shouldShowLoading {
  256. isLoadingProducts = true
  257. }
  258. defer {
  259. if shouldShowLoading {
  260. isLoadingProducts = false
  261. }
  262. }
  263. purchaseError = nil
  264. productLoadError = nil
  265. do {
  266. let loaded = try await loadProductsWithTimeout(seconds: 12)
  267. guard !loaded.isEmpty else {
  268. productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
  269. productsByID = [:]
  270. return
  271. }
  272. var map: [String: Product] = [:]
  273. for product in loaded {
  274. map[product.id] = product
  275. }
  276. productsByID = map
  277. productLoadError = nil
  278. await refreshPremiumAccess()
  279. await refreshTrialOffers()
  280. } catch is ProductLoadError {
  281. productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
  282. purchaseError = .productsLoadTimeout
  283. } catch {
  284. productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
  285. purchaseError = .generic
  286. }
  287. }
  288. private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
  289. try await withThrowingTaskGroup(of: [Product].self) { group in
  290. group.addTask {
  291. try await Product.products(for: SubscriptionProductID.all)
  292. }
  293. group.addTask {
  294. let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
  295. try await Task.sleep(nanoseconds: timeoutNanoseconds)
  296. throw ProductLoadError.timeout
  297. }
  298. guard let first = try await group.next() else {
  299. throw ProductLoadError.timeout
  300. }
  301. group.cancelAll()
  302. return first
  303. }
  304. }
  305. private func refreshPremiumAccess(trustedPlan: PaywallPlan? = nil) async {
  306. var resolvedPlan: PaywallPlan?
  307. if let lifetimePlan = await activeLifetimePlan() {
  308. resolvedPlan = lifetimePlan
  309. }
  310. if resolvedPlan == nil {
  311. for await result in Transaction.currentEntitlements {
  312. guard case .verified(let transaction) = result else { continue }
  313. guard let plan = planGrantingPremium(from: transaction) else { continue }
  314. resolvedPlan = preferredPlan(resolvedPlan, plan)
  315. }
  316. }
  317. if resolvedPlan == nil || resolvedPlan != .lifetime {
  318. if let subscriptionPlan = await activeSubscriptionPlan() {
  319. resolvedPlan = preferredPlan(resolvedPlan, subscriptionPlan)
  320. }
  321. }
  322. if resolvedPlan == nil {
  323. resolvedPlan = await activePlanFromLatestTransactions()
  324. }
  325. let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
  326. if let finalPlan {
  327. activePremiumPlan = finalPlan
  328. hasPremiumAccess = true
  329. launchedWithCachedPremium = false
  330. markAsHavingPurchasedPremium()
  331. } else if launchedWithCachedPremium, activePremiumPlan != nil {
  332. // StoreKit can lag behind on launch; keep the cached plan until reconciliation finishes.
  333. hasPremiumAccess = true
  334. } else {
  335. activePremiumPlan = nil
  336. hasPremiumAccess = false
  337. }
  338. let resolved = !launchedWithCachedPremium
  339. if resolved {
  340. hasResolvedPremiumStatus = true
  341. persistPremiumCache()
  342. await syncPurchaseHistory()
  343. }
  344. await refreshTrialOffers()
  345. }
  346. /// Re-reads entitlements from StoreKit, then syncs with the App Store when a
  347. /// purchase was just verified but entitlements have not caught up yet.
  348. private func reconcilePremiumAccess(trustedPlan: PaywallPlan?) async {
  349. await refreshPremiumAccess(trustedPlan: trustedPlan)
  350. guard let trustedPlan, !hasPremiumAccess else { return }
  351. do {
  352. try await syncAppStoreWithTimeout(seconds: 15)
  353. } catch {
  354. applyTrustedPlan(trustedPlan)
  355. return
  356. }
  357. await refreshPremiumAccess(trustedPlan: trustedPlan)
  358. if !hasPremiumAccess {
  359. applyTrustedPlan(trustedPlan)
  360. }
  361. }
  362. private func mergeTrustedPlan(_ resolvedPlan: PaywallPlan?, _ trustedPlan: PaywallPlan?) -> PaywallPlan? {
  363. switch (resolvedPlan, trustedPlan) {
  364. case (nil, nil):
  365. nil
  366. case (let resolved?, nil):
  367. resolved
  368. case (nil, let trusted?):
  369. trusted
  370. case (let resolved?, let trusted?):
  371. preferredPlan(resolved, trusted)
  372. }
  373. }
  374. private func applyTrustedPlan(_ plan: PaywallPlan) {
  375. launchedWithCachedPremium = false
  376. activePremiumPlan = preferredPlan(activePremiumPlan, plan)
  377. hasPremiumAccess = true
  378. hasResolvedPremiumStatus = true
  379. markAsHavingPurchasedPremium()
  380. persistPremiumCache()
  381. }
  382. private func revokePremiumAccess() {
  383. launchedWithCachedPremium = false
  384. activePremiumPlan = nil
  385. hasPremiumAccess = false
  386. hasResolvedPremiumStatus = true
  387. markAsHavingPurchasedPremium()
  388. AIFreeUsageManager.shared.forfeitFreeUses()
  389. persistPremiumCache()
  390. }
  391. private func syncAppStoreWithTimeout(seconds: TimeInterval) async throws {
  392. try await withThrowingTaskGroup(of: Void.self) { group in
  393. group.addTask {
  394. try await AppStore.sync()
  395. }
  396. group.addTask {
  397. let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
  398. try await Task.sleep(nanoseconds: timeoutNanoseconds)
  399. throw StoreSyncError.timeout
  400. }
  401. try await group.next()
  402. group.cancelAll()
  403. }
  404. }
  405. private func applyPremiumAccess(from transaction: StoreKit.Transaction) {
  406. guard let plan = planGrantingPremium(from: transaction) else { return }
  407. launchedWithCachedPremium = false
  408. activePremiumPlan = preferredPlan(activePremiumPlan, plan)
  409. hasPremiumAccess = true
  410. hasResolvedPremiumStatus = true
  411. markAsHavingPurchasedPremium()
  412. persistPremiumCache()
  413. }
  414. private func markAsHavingPurchasedPremium() {
  415. let wasAlreadyMarked = hasEverPurchasedPremium
  416. hasEverPurchasedPremium = true
  417. trialDisplayByPlan = [:]
  418. UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
  419. if !wasAlreadyMarked {
  420. AIFreeUsageManager.shared.forfeitFreeUses()
  421. }
  422. }
  423. /// Syncs `hasEverPurchasedPremium` with StoreKit history.
  424. /// - Refund: revoked transactions remain in history → flag stays `true` → no trial badge.
  425. /// - Sandbox reset: no transactions → flag clears → trial badge returns for testing.
  426. private func syncPurchaseHistory() async {
  427. var foundPurchase = false
  428. for await result in Transaction.all {
  429. guard case .verified(let transaction) = result else { continue }
  430. guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
  431. foundPurchase = true
  432. if !hasEverPurchasedPremium {
  433. hasEverPurchasedPremium = true
  434. UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
  435. AIFreeUsageManager.shared.forfeitFreeUses()
  436. }
  437. return
  438. }
  439. if !foundPurchase, hasEverPurchasedPremium {
  440. hasEverPurchasedPremium = false
  441. UserDefaults.standard.set(false, forKey: Self.cachedHasEverPurchasedKey)
  442. }
  443. }
  444. private func refreshTrialOffers() async {
  445. // Trial badge is first-time users only — never after a purchase or refund.
  446. guard isEligibleForIntroTrial else {
  447. trialDisplayByPlan = [:]
  448. return
  449. }
  450. guard let eligiblePlan = PaywallConfigService.shared.config.trialEligiblePlan else {
  451. trialDisplayByPlan = [:]
  452. return
  453. }
  454. if let display = await resolveTrialDisplay(for: eligiblePlan) {
  455. trialDisplayByPlan = [eligiblePlan: display]
  456. } else {
  457. trialDisplayByPlan = [:]
  458. }
  459. }
  460. private func resolveTrialDisplay(for plan: PaywallPlan) async -> PaywallTrialDisplay? {
  461. let config = PaywallConfigService.shared.config
  462. guard plan == config.trialEligiblePlan else { return nil }
  463. guard let fallback = config.trial.fallbackDuration else { return nil }
  464. if let product = product(for: plan),
  465. let subscription = product.subscription,
  466. await subscription.isEligibleForIntroOffer,
  467. let offer = subscription.introductoryOffer {
  468. return PaywallTrialDisplay.from(offer: offer, config: config)
  469. }
  470. return PaywallTrialDisplay.from(fallback: fallback, config: config)
  471. }
  472. private func persistPremiumCache() {
  473. UserDefaults.standard.set(hasPremiumAccess, forKey: Self.cachedPremiumKey)
  474. if let plan = activePremiumPlan?.rawValue {
  475. UserDefaults.standard.set(plan, forKey: Self.cachedPremiumPlanKey)
  476. } else {
  477. UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
  478. }
  479. }
  480. private func planGrantingPremium(from transaction: StoreKit.Transaction) -> PaywallPlan? {
  481. guard transactionGrantsPremium(transaction) else { return nil }
  482. return SubscriptionProductID.plan(for: transaction.productID)
  483. }
  484. private func transactionGrantsPremium(_ transaction: StoreKit.Transaction) -> Bool {
  485. guard SubscriptionProductID.all.contains(transaction.productID) else { return false }
  486. guard transaction.revocationDate == nil else { return false }
  487. if let expirationDate = transaction.expirationDate, expirationDate < Date() {
  488. return false
  489. }
  490. return true
  491. }
  492. private func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
  493. if current == .lifetime || candidate == .lifetime {
  494. return .lifetime
  495. }
  496. if current == .yearly || candidate == .yearly {
  497. return .yearly
  498. }
  499. if current == .monthly || candidate == .monthly {
  500. return .monthly
  501. }
  502. return candidate
  503. }
  504. private func activePlanFromLatestTransactions() async -> PaywallPlan? {
  505. var resolvedPlan: PaywallPlan?
  506. for productID in SubscriptionProductID.all {
  507. guard let result = await Transaction.latest(for: productID) else { continue }
  508. guard case .verified(let transaction) = result else { continue }
  509. guard let plan = planGrantingPremium(from: transaction) else { continue }
  510. resolvedPlan = preferredPlan(resolvedPlan, plan)
  511. }
  512. return resolvedPlan
  513. }
  514. private func activeLifetimePlan() async -> PaywallPlan? {
  515. guard let result = await Transaction.latest(for: SubscriptionProductID.lifetime) else {
  516. return nil
  517. }
  518. guard case .verified(let transaction) = result else { return nil }
  519. return planGrantingPremium(from: transaction)
  520. }
  521. private func activeSubscriptionPlan() async -> PaywallPlan? {
  522. var resolvedPlan: PaywallPlan?
  523. for await result in Transaction.currentEntitlements {
  524. guard case .verified(let transaction) = result else { continue }
  525. guard let plan = planGrantingPremium(from: transaction), plan != .lifetime else { continue }
  526. resolvedPlan = preferredPlan(resolvedPlan, plan)
  527. }
  528. return resolvedPlan
  529. }
  530. private func listenForTransactionUpdates() async {
  531. for await update in Transaction.updates {
  532. guard case .verified(let transaction) = update else { continue }
  533. if isRevokedAppTransaction(transaction) {
  534. revokePremiumAccess()
  535. await transaction.finish()
  536. await refreshPremiumAccess()
  537. continue
  538. }
  539. let trustedPlan = planGrantingPremium(from: transaction)
  540. if let trustedPlan {
  541. applyPremiumAccess(from: transaction)
  542. }
  543. await transaction.finish()
  544. if trustedPlan != nil {
  545. await reconcilePremiumAccess(trustedPlan: trustedPlan)
  546. } else {
  547. await refreshPremiumAccess()
  548. }
  549. }
  550. }
  551. private func isRevokedAppTransaction(_ transaction: StoreKit.Transaction) -> Bool {
  552. SubscriptionProductID.all.contains(transaction.productID) && transaction.revocationDate != nil
  553. }
  554. }