SubscriptionManager.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  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 purchases 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. static let maxFreeAIUses = 4
  35. private static let freeAIUsesConsumedKey = "freeAIUsesConsumed"
  36. private enum ProductLoadError: Error {
  37. case timeout
  38. }
  39. private static let cachedPremiumAccessKey = "subscription.cachedPremiumAccess"
  40. @Published private(set) var productsByID: [String: Product] = [:]
  41. @Published private(set) var isLoadingProducts = false
  42. @Published private(set) var purchasingPlan: PaywallPlan?
  43. @Published private(set) var hasPremiumAccess = false
  44. @Published private(set) var hasManageableSubscription = false
  45. @Published private(set) var hasResolvedPremiumStatus = false
  46. @Published private(set) var activePlan: PaywallPlan?
  47. @Published private(set) var activeRecurringPlan: PaywallPlan?
  48. @Published private(set) var introOfferEligibleByPlan: [PaywallPlan: Bool] = [:]
  49. @Published private(set) var freeAIUsesConsumed = 0
  50. @Published var purchaseError: PurchaseError?
  51. @Published var subscriptionSuccessMessage: String?
  52. @Published var showsCancelSubscriptionReminder = false
  53. @Published private(set) var recurringPlanPendingCancellation: PaywallPlan?
  54. var hasAllProductsLoaded: Bool {
  55. SubscriptionProductID.all.allSatisfy { productsByID[$0] != nil }
  56. }
  57. var remainingFreeAIUses: Int {
  58. max(0, Self.maxFreeAIUses - freeAIUsesConsumed)
  59. }
  60. var canUseAIFeatures: Bool {
  61. hasPremiumAccess || remainingFreeAIUses > 0
  62. }
  63. private var inFlightProductLoad: Task<Void, Never>?
  64. private var cancellables = Set<AnyCancellable>()
  65. private let defaults: UserDefaults
  66. init(defaults: UserDefaults = .standard) {
  67. self.defaults = defaults
  68. hasPremiumAccess = defaults.bool(forKey: Self.cachedPremiumAccessKey)
  69. freeAIUsesConsumed = min(
  70. defaults.integer(forKey: Self.freeAIUsesConsumedKey),
  71. Self.maxFreeAIUses
  72. )
  73. observeAIFeatureUsage()
  74. Task { await listenForTransactionUpdates() }
  75. Task { await refreshPremiumAccess() }
  76. Task { await loadProducts(presentLoadingUI: false) }
  77. observeAppLifecycle()
  78. }
  79. func recordSuccessfulAIUse() {
  80. guard !hasPremiumAccess else { return }
  81. guard freeAIUsesConsumed < Self.maxFreeAIUses else { return }
  82. freeAIUsesConsumed += 1
  83. defaults.set(freeAIUsesConsumed, forKey: Self.freeAIUsesConsumedKey)
  84. }
  85. private func observeAIFeatureUsage() {
  86. NotificationCenter.default.publisher(for: .aiFeatureDidSucceed)
  87. .receive(on: DispatchQueue.main)
  88. .sink { [weak self] _ in
  89. self?.recordSuccessfulAIUse()
  90. }
  91. .store(in: &cancellables)
  92. }
  93. func loadProducts(presentLoadingUI: Bool? = nil) async {
  94. if let existing = inFlightProductLoad {
  95. await existing.value
  96. return
  97. }
  98. let uiFlag = presentLoadingUI
  99. let task = Task { @MainActor in
  100. await performLoadProducts(presentLoadingUI: uiFlag)
  101. }
  102. inFlightProductLoad = task
  103. await task.value
  104. inFlightProductLoad = nil
  105. }
  106. func product(for plan: PaywallPlan) -> Product? {
  107. productsByID[plan.productID]
  108. }
  109. func displayTitle(for plan: PaywallPlan) -> String {
  110. product(for: plan)?.displayName ?? plan.rawValue.capitalized
  111. }
  112. func mainPrice(for plan: PaywallPlan) -> String {
  113. product(for: plan)?.displayPrice ?? "—"
  114. }
  115. func secondaryPrice(for plan: PaywallPlan) -> String {
  116. guard let product = product(for: plan) else { return "—" }
  117. let formatStyle = product.priceFormatStyle
  118. switch plan {
  119. case .weekly:
  120. let doubled = product.price * 2
  121. return "\(doubled.formatted(formatStyle)) / week"
  122. case .monthly:
  123. let weekly = product.price / Decimal(4.33)
  124. return "\(weekly.formatted(formatStyle)) / week"
  125. case .yearly:
  126. let weekly = product.price / Decimal(52)
  127. return "\(weekly.formatted(formatStyle)) / week"
  128. case .lifetime:
  129. let doubled = product.price * 2
  130. return doubled.formatted(formatStyle)
  131. }
  132. }
  133. func tagText(for plan: PaywallPlan) -> String? {
  134. if hasFreeTrialOffer(for: plan) {
  135. return "Free Trial"
  136. }
  137. switch plan {
  138. case .weekly:
  139. return "Basic"
  140. case .yearly:
  141. return savingsTagText(for: plan)
  142. case .lifetime:
  143. return "Pay Once"
  144. case .monthly:
  145. return nil
  146. }
  147. }
  148. func ctaTitle(for plan: PaywallPlan) -> String {
  149. if hasFreeTrialOffer(for: plan) {
  150. return "START FOR FREE"
  151. }
  152. switch plan {
  153. case .weekly: return "START WEEKLY PLAN"
  154. case .monthly: return "START MONTHLY PLAN"
  155. case .yearly: return "START YEARLY PLAN"
  156. case .lifetime: return "UNLOCK LIFETIME"
  157. }
  158. }
  159. func billingDescription(for plan: PaywallPlan) -> String {
  160. guard let product = product(for: plan) else {
  161. return "Loading pricing…"
  162. }
  163. if hasFreeTrialOffer(for: plan),
  164. let subscription = product.subscription,
  165. let intro = subscription.introductoryOffer {
  166. let trialPeriod = formattedPeriod(intro.period)
  167. let billingPeriod = formattedSubscriptionPeriod(subscription.subscriptionPeriod)
  168. return "\(trialPeriod) Free Trial, then \(product.displayPrice) per \(billingPeriod)"
  169. }
  170. switch plan {
  171. case .weekly:
  172. return "Billed at \(product.displayPrice) every week"
  173. case .monthly:
  174. return "Billed at \(product.displayPrice) per month"
  175. case .yearly:
  176. return "Billed at \(product.displayPrice) every year"
  177. case .lifetime:
  178. return "One-time payment of \(product.displayPrice)"
  179. }
  180. }
  181. func showsStrikethroughSecondaryPrice(for plan: PaywallPlan) -> Bool {
  182. plan == .weekly || plan == .lifetime
  183. }
  184. private func savingsTagText(for plan: PaywallPlan) -> String? {
  185. guard plan == .yearly,
  186. let yearlyProduct = product(for: .yearly),
  187. let weeklyProduct = product(for: .weekly),
  188. weeklyProduct.price > 0 else { return nil }
  189. let yearlyWeeklyEquivalent = yearlyProduct.price / Decimal(52)
  190. let savings = (1 - (yearlyWeeklyEquivalent / weeklyProduct.price)) * 100
  191. let rounded = Int(NSDecimalNumber(decimal: savings).doubleValue.rounded())
  192. guard rounded > 0 else { return nil }
  193. return "Save \(rounded)%"
  194. }
  195. func hasFreeTrialOffer(for plan: PaywallPlan) -> Bool {
  196. guard introOfferEligibleByPlan[plan] == true else { return false }
  197. guard let product = product(for: plan),
  198. let subscription = product.subscription,
  199. let intro = subscription.introductoryOffer,
  200. intro.paymentMode == .freeTrial else { return false }
  201. return true
  202. }
  203. func purchase(_ plan: PaywallPlan) async -> Bool {
  204. purchaseError = nil
  205. if hasPremiumAccess {
  206. if !hasManageableSubscription {
  207. return true
  208. }
  209. if let activePlan, plan == activePlan {
  210. return true
  211. }
  212. if let activeRecurringPlan, plan == activeRecurringPlan {
  213. return true
  214. }
  215. }
  216. var product = productsByID[plan.productID]
  217. if product == nil {
  218. await loadProducts(presentLoadingUI: false)
  219. product = productsByID[plan.productID]
  220. }
  221. guard let product else {
  222. purchaseError = productsByID.isEmpty ? .productsLoadTimeout : .planUnavailable
  223. #if DEBUG
  224. let missingProductIDs = SubscriptionProductID.all.filter { productsByID[$0] == nil }
  225. if !missingProductIDs.isEmpty {
  226. print(
  227. """
  228. [SubscriptionManager] Missing StoreKit products: \(missingProductIDs.joined(separator: ", "))
  229. To test locally, attach `Paywall.storekit` in Xcode:
  230. Product > Scheme > Edit Scheme > Run > Options > StoreKit Configuration.
  231. """
  232. )
  233. }
  234. #endif
  235. return false
  236. }
  237. purchasingPlan = plan
  238. defer { purchasingPlan = nil }
  239. let shouldRemindToCancelSubscription = plan == .lifetime && hasManageableSubscription
  240. let recurringPlanBeforePurchase = activeRecurringPlan ?? activePlan
  241. do {
  242. let result = try await product.purchase()
  243. switch result {
  244. case .success(let verification):
  245. guard case .verified(let transaction) = verification else {
  246. purchaseError = .purchaseNotVerified
  247. return false
  248. }
  249. await transaction.finish()
  250. applyOptimisticPremiumAccess(for: transaction)
  251. await refreshPremiumAccessWithRetry()
  252. if hasPremiumAccess {
  253. NotificationCenter.default.post(name: .subscriptionPurchased, object: nil)
  254. }
  255. if shouldRemindToCancelSubscription {
  256. recurringPlanPendingCancellation = recurringPlanBeforePurchase
  257. showsCancelSubscriptionReminder = true
  258. } else if !hasPremiumAccess {
  259. subscriptionSuccessMessage = "Purchase successful. If premium features don't unlock right away, tap Restore Purchases."
  260. }
  261. return hasPremiumAccess || shouldRemindToCancelSubscription
  262. case .userCancelled:
  263. return false
  264. case .pending:
  265. purchaseError = .purchasePending
  266. return false
  267. @unknown default:
  268. purchaseError = .generic
  269. return false
  270. }
  271. } catch {
  272. purchaseError = .generic
  273. return false
  274. }
  275. }
  276. func openSubscriptionManagement() {
  277. guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
  278. NSWorkspace.shared.open(url)
  279. }
  280. func restorePurchases() async {
  281. purchaseError = nil
  282. subscriptionSuccessMessage = nil
  283. let hadPremium = hasPremiumAccess
  284. do {
  285. try await AppStore.sync()
  286. await refreshPremiumAccess()
  287. if hasPremiumAccess {
  288. if !hadPremium {
  289. subscriptionSuccessMessage = "Your purchases have been restored."
  290. }
  291. } else {
  292. purchaseError = .noActiveSubscriptions
  293. }
  294. } catch {
  295. purchaseError = .generic
  296. }
  297. }
  298. func clearSubscriptionSuccessMessage() {
  299. subscriptionSuccessMessage = nil
  300. }
  301. func clearCancelSubscriptionReminder() {
  302. showsCancelSubscriptionReminder = false
  303. recurringPlanPendingCancellation = nil
  304. }
  305. var cancelSubscriptionReminderMessage: String {
  306. if let plan = recurringPlanPendingCancellation {
  307. let planName = displayTitle(for: plan)
  308. return "Your lifetime access is active. Your \(planName) subscription is still active and will renew automatically. Cancel it in the App Store to avoid future charges."
  309. }
  310. return "Your lifetime access is active. Your recurring subscription is still active and will renew automatically. Cancel it in the App Store to avoid future charges."
  311. }
  312. private func performLoadProducts(presentLoadingUI: Bool?) async {
  313. let missingAny = SubscriptionProductID.all.contains { productsByID[$0] == nil }
  314. let shouldShowLoading = presentLoadingUI ?? missingAny
  315. if !missingAny, presentLoadingUI != true {
  316. purchaseError = nil
  317. await refreshIntroOfferEligibility()
  318. await refreshPremiumAccess()
  319. return
  320. }
  321. if shouldShowLoading {
  322. isLoadingProducts = true
  323. }
  324. defer {
  325. if shouldShowLoading {
  326. isLoadingProducts = false
  327. }
  328. }
  329. purchaseError = nil
  330. do {
  331. let loaded = try await loadProductsWithTimeout(seconds: 12)
  332. var map: [String: Product] = [:]
  333. for product in loaded {
  334. map[product.id] = product
  335. }
  336. productsByID = map
  337. await refreshIntroOfferEligibility()
  338. await refreshPremiumAccess()
  339. } catch is ProductLoadError {
  340. if presentLoadingUI == true {
  341. purchaseError = .productsLoadTimeout
  342. }
  343. } catch {
  344. if presentLoadingUI == true {
  345. purchaseError = .generic
  346. }
  347. }
  348. }
  349. private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
  350. try await withThrowingTaskGroup(of: [Product].self) { group in
  351. group.addTask {
  352. try await Product.products(for: SubscriptionProductID.all)
  353. }
  354. group.addTask {
  355. let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
  356. try await Task.sleep(nanoseconds: timeoutNanoseconds)
  357. throw ProductLoadError.timeout
  358. }
  359. guard let first = try await group.next() else {
  360. throw ProductLoadError.timeout
  361. }
  362. group.cancelAll()
  363. return first
  364. }
  365. }
  366. private func refreshPremiumAccess() async {
  367. var hasPremium = false
  368. var manageableSubscription = false
  369. var resolvedPlan: PaywallPlan?
  370. var resolvedRecurringPlan: PaywallPlan?
  371. for await result in Transaction.currentEntitlements {
  372. guard case .verified(let transaction) = result else { continue }
  373. guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
  374. guard transaction.revocationDate == nil else { continue }
  375. guard let plan = SubscriptionProductID.plan(for: transaction.productID) else { continue }
  376. if SubscriptionProductID.recurring.contains(transaction.productID) {
  377. guard await isRecurringSubscriptionEntitled(productID: transaction.productID) else {
  378. continue
  379. }
  380. manageableSubscription = true
  381. if resolvedRecurringPlan == nil {
  382. resolvedRecurringPlan = plan
  383. }
  384. }
  385. hasPremium = true
  386. if resolvedPlan == nil {
  387. resolvedPlan = plan
  388. }
  389. }
  390. hasPremiumAccess = hasPremium
  391. hasManageableSubscription = manageableSubscription
  392. activePlan = resolvedPlan
  393. activeRecurringPlan = resolvedRecurringPlan
  394. hasResolvedPremiumStatus = true
  395. defaults.set(hasPremium, forKey: Self.cachedPremiumAccessKey)
  396. }
  397. private func applyOptimisticPremiumAccess(for transaction: Transaction) {
  398. guard SubscriptionProductID.all.contains(transaction.productID),
  399. transaction.revocationDate == nil,
  400. let plan = SubscriptionProductID.plan(for: transaction.productID) else { return }
  401. hasPremiumAccess = true
  402. activePlan = plan
  403. if SubscriptionProductID.recurring.contains(transaction.productID) {
  404. hasManageableSubscription = true
  405. activeRecurringPlan = plan
  406. }
  407. defaults.set(true, forKey: Self.cachedPremiumAccessKey)
  408. }
  409. private func refreshIntroOfferEligibility() async {
  410. var eligibility: [PaywallPlan: Bool] = [:]
  411. for plan in PaywallPlan.allCases {
  412. guard let product = productsByID[plan.productID],
  413. let subscription = product.subscription,
  414. let intro = subscription.introductoryOffer,
  415. intro.paymentMode == .freeTrial else { continue }
  416. eligibility[plan] = await subscription.isEligibleForIntroOffer
  417. }
  418. introOfferEligibleByPlan = eligibility
  419. }
  420. private func refreshPremiumAccessWithRetry() async {
  421. await refreshPremiumAccess()
  422. guard !hasPremiumAccess else { return }
  423. for _ in 0..<6 {
  424. try? await Task.sleep(nanoseconds: 500_000_000)
  425. await refreshPremiumAccess()
  426. if hasPremiumAccess { return }
  427. }
  428. }
  429. private func isRecurringSubscriptionEntitled(productID: String) async -> Bool {
  430. let product: Product?
  431. if let cached = productsByID[productID] {
  432. product = cached
  433. } else if let loaded = try? await Product.products(for: [productID]).first {
  434. product = loaded
  435. } else {
  436. // Entitlement is already in currentEntitlements; defer to StoreKit when product metadata is unavailable.
  437. return true
  438. }
  439. guard let product, let subscription = product.subscription else { return true }
  440. do {
  441. let statuses = try await subscription.status
  442. return statuses.contains { status in
  443. switch status.state {
  444. case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
  445. return true
  446. case .expired, .revoked:
  447. return false
  448. default:
  449. return false
  450. }
  451. }
  452. } catch {
  453. return false
  454. }
  455. }
  456. private func observeAppLifecycle() {
  457. NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)
  458. .sink { [weak self] _ in
  459. Task { await self?.refreshPremiumAccess() }
  460. }
  461. .store(in: &cancellables)
  462. }
  463. private func listenForTransactionUpdates() async {
  464. for await update in Transaction.updates {
  465. switch update {
  466. case .verified(let transaction):
  467. await transaction.finish()
  468. await refreshPremiumAccess()
  469. case .unverified(_, let error):
  470. #if DEBUG
  471. print("[SubscriptionManager] Unverified transaction update: \(error)")
  472. #endif
  473. purchaseError = .purchaseNotVerified
  474. }
  475. }
  476. }
  477. private func formattedPeriod(_ period: Product.SubscriptionPeriod) -> String {
  478. switch period.unit {
  479. case .day where period.value == 1: "1 Day"
  480. case .day: "\(period.value) Days"
  481. case .week where period.value == 1: "1 Week"
  482. case .week: "\(period.value) Weeks"
  483. case .month where period.value == 1: "1 Month"
  484. case .month: "\(period.value) Months"
  485. case .year where period.value == 1: "1 Year"
  486. case .year: "\(period.value) Years"
  487. @unknown default: "\(period.value) Days"
  488. }
  489. }
  490. private func formattedSubscriptionPeriod(_ period: Product.SubscriptionPeriod) -> String {
  491. switch period.unit {
  492. case .day: period.value == 1 ? "day" : "\(period.value) days"
  493. case .week: period.value == 1 ? "week" : "\(period.value) weeks"
  494. case .month: period.value == 1 ? "month" : "\(period.value) months"
  495. case .year: period.value == 1 ? "year" : "\(period.value) years"
  496. @unknown default: "billing period"
  497. }
  498. }
  499. }