SubscriptionManager.swift 23 KB

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