SubscriptionManager.swift 20 KB

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