import Foundation import StoreKit import TermindDomain @MainActor @Observable public final class ProAccessController { public static let lifetimeProductID = "com.akinokaede.termind.pro" public static let annualProductID = "com.akinokaede.termind.pro.annual" private let now: () -> Date private var transactionUpdates: Task? private var refreshTask: Task? private var accessTask: Task? private var metadataTask: Task? private var productsTask: Task<[Product], any Error>? private var entitlementExpirationTask: Task? private var refreshGeneration: UInt64 = 0 private var metadataError: ProAccessControllerError? public private(set) var status: EntitlementStatus /// The permanent, non-consumable product. Kept under the existing name for source compatibility. public private(set) var product: Product? public private(set) var annualProduct: Product? public private(set) var isEligibleForAnnualIntroOffer: Bool? public private(set) var error: ProAccessControllerError? public private(set) var isProcessing = false public private(set) var pendingProductID: String? public var isPaywallPresented = false public var requestedFeature: ProFeature? public var displayPrice: String? { product?.displayPrice } public var annualDisplayPrice: String? { annualProduct?.displayPrice } public var annualBillingPeriod: DateComponents? { annualProduct?.subscription.map { Self.dateComponents(for: $0.subscriptionPeriod, count: 1) } } public var annualIntroOfferDuration: DateComponents? { guard let offer = Self.freeIntroductoryOffer(for: annualProduct) else { return nil } return Self.dateComponents(for: offer.period, count: offer.periodCount) } public var hasProAccess: Bool { status.hasProAccess } public var canPurchase: Bool { canPurchase(.lifetime) } public var canPurchaseAnnual: Bool { canPurchase(.annual) } public var canStartTrial: Bool { canPurchase(.annual) && isEligibleForAnnualIntroOffer == true && Self.freeIntroductoryOffer(for: annualProduct) != nil } public var message: String? { if let error { return error.localizedDescription } if pendingProductID != nil { return ProAccessControllerError.purchasePending.localizedDescription } if status.canPurchase, let metadataError { return metadataError.localizedDescription } return nil } public static func live() -> ProAccessController { ProAccessController() } public static func unrestrictedForTesting() -> ProAccessController { testing(status: .unrestrictedForTesting) } public static func testing(status: EntitlementStatus) -> ProAccessController { let controller = ProAccessController(observeStore: false) controller.status = status return controller } public init( now: @escaping () -> Date = { .now }, observeStore: Bool = true ) { self.now = now status = .loading guard observeStore else { return } transactionUpdates = Task { [weak self] in for await update in Transaction.updates { guard !Task.isCancelled, let self else { return } await self.handleTransactionUpdate(update) } } refreshTask = Task { [weak self] in await self?.refresh() } metadataTask = Task { [weak self] in await self?.reloadProducts() } } isolated deinit { transactionUpdates?.cancel() refreshTask?.cancel() accessTask?.cancel() metadataTask?.cancel() productsTask?.cancel() entitlementExpirationTask?.cancel() } public func canUse(_: ProFeature) -> Bool { status.hasProAccess } public func canPurchase(_ plan: ProPurchasePlan) -> Bool { guard status.canPurchase, !isProcessing else { return false } switch plan { case .annual: return pendingProductID != Self.annualProductID && annualProduct != nil case .lifetime: return pendingProductID != Self.lifetimeProductID && product != nil } } @discardableResult public func require(_ feature: ProFeature) -> Bool { guard !canUse(feature) else { return true } requestedFeature = feature isPaywallPresented = true return false } public func presentPaywall(for feature: ProFeature? = nil) { requestedFeature = feature isPaywallPresented = true } /// StoreKit's local transactions resolve access independently of network product metadata. public func refresh() async { await refresh(including: nil) } private func refresh(including transaction: Transaction?) async { if status == .unrestrictedForTesting { return } if accessTask == nil || transaction != nil { accessTask?.cancel() refreshGeneration &+= 1 accessTask = Task { [weak self] in await self?.currentStatus(including: transaction) ?? .loading } } let generation = refreshGeneration guard let task = accessTask else { return } let resolved = await task.value guard generation == refreshGeneration else { if accessTask != nil { await refresh() } return } guard accessTask != nil else { return } accessTask = nil switch resolved { case .purchased: pendingProductID = nil case .subscription(let subscription) where subscription.phase.hasProAccess: if pendingProductID == Self.annualProductID { pendingProductID = nil } default: break } apply(resolved) } /// Purchases the annual subscription. StoreKit applies its introductory offer for eligible accounts. public func purchaseAnnual() async { await purchaseProduct(Self.annualProductID) } public func purchaseLifetime() async { await purchaseProduct(Self.lifetimeProductID) } private func purchaseProduct(_ id: String) async { guard !isProcessing, pendingProductID != id, status.canPurchase else { return } isProcessing = true error = nil defer { isProcessing = false } await refresh() guard status.canPurchase else { return } do { try await fetchProducts() let selected: Product switch id { case Self.annualProductID: guard let annualProduct else { throw ProAccessControllerError.productUnavailable } selected = annualProduct case Self.lifetimeProductID: guard let product else { throw ProAccessControllerError.productUnavailable } selected = product default: throw ProAccessControllerError.productUnavailable } switch try await selected.purchase() { case .success(let verification): guard case .verified(let transaction) = verification, transaction.productID == id else { error = .purchaseVerificationFailed return } await refresh(including: transaction) await transaction.finish() if status.hasProAccess { isPaywallPresented = false } case .pending: pendingProductID = id case .userCancelled: break @unknown default: error = .purchaseVerificationFailed } } catch StoreKitError.userCancelled { // Cancellation is not an entitlement change or a failure to display. } catch let purchaseError { error = purchaseError as? ProAccessControllerError ?? .operationFailed(purchaseError.localizedDescription) } } public func restorePurchases() async { guard !isProcessing else { return } isProcessing = true error = nil defer { isProcessing = false } do { try await AppStore.sync() await refresh() switch status { case .purchased: isPaywallPresented = false case .subscription(let subscription) where subscription.phase.hasProAccess: isPaywallPresented = false case .verificationFailed: error = .purchaseVerificationFailed default: error = .purchaseNotFound } } catch { self.error = .operationFailed(error.localizedDescription) } } public func reloadProducts() async { do { try await fetchProducts() } catch { product = nil annualProduct = nil isEligibleForAnnualIntroOffer = nil metadataError = .productMetadataUnavailable } } private func fetchProducts() async throws { let task = productsTask ?? Task { try await Product.products(for: [Self.lifetimeProductID, Self.annualProductID]) } productsTask = task defer { productsTask = nil } let loaded = try await task.value product = loaded.first { Self.isLifetimeProduct($0) } annualProduct = loaded.first { Self.isAnnualSubscription($0) } if let subscription = annualProduct?.subscription, Self.freeIntroductoryOffer(for: annualProduct) != nil { isEligibleForAnnualIntroOffer = await subscription.isEligibleForIntroOffer } else { isEligibleForAnnualIntroOffer = false } if product == nil, annualProduct == nil { metadataError = .productUnavailable } else { metadataError = nil } if case .free = status { apply(.free(trialAvailable: isEligibleForAnnualIntroOffer == true)) } } public static func isLifetimeProduct(_ product: Product) -> Bool { product.id == lifetimeProductID && product.type == .nonConsumable && product.isFamilyShareable } public static func isAnnualSubscription(_ product: Product) -> Bool { guard product.id == annualProductID, product.type == .autoRenewable, product.isFamilyShareable, let subscription = product.subscription else { return false } return subscription.subscriptionPeriod.unit == .year && subscription.subscriptionPeriod.value == 1 } public static func freeIntroductoryOffer(for product: Product?) -> Product.SubscriptionOffer? { guard let offer = product?.subscription?.introductoryOffer, offer.type == .introductory, offer.paymentMode == .freeTrial, offer.price == .zero else { return nil } return offer } private func currentStatus(including update: Transaction?) async -> EntitlementStatus { let lifetimeResult = await currentLifetimeTransaction(including: update) if let lifetime = lifetimeResult.transaction { return .purchased(Self.ownership(of: lifetime)) } var verificationFailed = lifetimeResult.verificationFailed let annualResult: VerificationResult? if let update, update.productID == Self.annualProductID { annualResult = .verified(update) } else { annualResult = await Transaction.latest(for: Self.annualProductID) } if let annualResult { switch annualResult { case .verified(let transaction): return .subscription(await subscriptionStatus(for: transaction)) case .unverified: verificationFailed = true } } return verificationFailed ? .verificationFailed : .free(trialAvailable: isEligibleForAnnualIntroOffer == true) } private func currentLifetimeTransaction( including update: Transaction? ) async -> (transaction: Transaction?, verificationFailed: Bool) { if let update, update.productID == Self.lifetimeProductID, update.productType == .nonConsumable, update.revocationDate == nil { return (update, false) } var verificationFailed = false for await result in Transaction.currentEntitlements { switch result { case .verified(let transaction) where transaction.productID == Self.lifetimeProductID && transaction.revocationDate == nil && transaction.productType == .nonConsumable && !(update?.productID == Self.lifetimeProductID && update?.revocationDate != nil && update?.originalID == transaction.originalID): return (transaction, false) case .unverified(let transaction, _) where transaction.productID == Self.lifetimeProductID: verificationFailed = true default: break } } if let latest = await Transaction.latest(for: Self.lifetimeProductID) { switch latest { case .verified(let transaction) where transaction.revocationDate == nil && transaction.productType == .nonConsumable && !(update?.productID == Self.lifetimeProductID && update?.revocationDate != nil && update?.originalID == transaction.originalID): return (transaction, false) case .unverified: verificationFailed = true default: break } } return (nil, verificationFailed) } private func subscriptionStatus(for transaction: Transaction) async -> ProSubscriptionStatus { let ownership = Self.ownership(of: transaction) if transaction.revocationDate != nil || transaction.productType != .autoRenewable { return ProSubscriptionStatus( phase: .revoked, expiresAt: transaction.expirationDate, willAutoRenew: false, ownership: ownership) } let storeStatus = await transaction.subscriptionStatus let renewalInfo: Product.SubscriptionInfo.RenewalInfo? if let storeStatus, case .verified(let verified) = storeStatus.renewalInfo { renewalInfo = verified } else { renewalInfo = nil } if let expiration = transaction.expirationDate, expiration > now(), !transaction.isUpgraded { let phase: ProSubscriptionPhase = Self.isIntroductoryTrial(transaction) ? .introductoryTrial : .subscribed return ProSubscriptionStatus( phase: phase, expiresAt: expiration, willAutoRenew: renewalInfo?.willAutoRenew, ownership: ownership) } switch storeStatus?.state { case .inGracePeriod: if let graceExpiration = renewalInfo?.gracePeriodExpirationDate, graceExpiration > now() { return ProSubscriptionStatus( phase: .gracePeriod, expiresAt: graceExpiration, willAutoRenew: renewalInfo?.willAutoRenew, ownership: ownership) } return ProSubscriptionStatus( phase: .billingRetry, expiresAt: transaction.expirationDate, willAutoRenew: renewalInfo?.willAutoRenew, ownership: ownership) case .inBillingRetryPeriod: return ProSubscriptionStatus( phase: .billingRetry, expiresAt: transaction.expirationDate, willAutoRenew: renewalInfo?.willAutoRenew, ownership: ownership) case .revoked: return ProSubscriptionStatus( phase: .revoked, expiresAt: transaction.expirationDate, willAutoRenew: false, ownership: ownership) case .subscribed: return ProSubscriptionStatus( phase: .subscribed, expiresAt: transaction.expirationDate, willAutoRenew: renewalInfo?.willAutoRenew, ownership: ownership) case .expired, .none: return ProSubscriptionStatus( phase: .expired, expiresAt: transaction.expirationDate, willAutoRenew: false, ownership: ownership) default: return ProSubscriptionStatus( phase: .expired, expiresAt: transaction.expirationDate, willAutoRenew: false, ownership: ownership) } } private static func isIntroductoryTrial(_ transaction: Transaction) -> Bool { transaction.offer?.type == .introductory && transaction.offer?.paymentMode == .freeTrial } private static func ownership(of transaction: Transaction) -> ProOwnership { transaction.ownershipType == .familyShared ? .familyShared : .purchased } public func handleTransactionUpdate(_ update: VerificationResult) async { let knownProductIDs = [Self.lifetimeProductID, Self.annualProductID] guard knownProductIDs.contains(update.unsafePayloadValue.productID) else { return } if case .verified(let transaction) = update { error = nil if pendingProductID == transaction.productID { pendingProductID = nil } await refresh(including: transaction) await transaction.finish() } else { error = .purchaseVerificationFailed await refresh() } } private func apply(_ next: EntitlementStatus) { status = next entitlementExpirationTask?.cancel() let expiresAt: Date? switch next { case .subscription(let subscription) where subscription.phase.hasProAccess: expiresAt = subscription.expiresAt default: expiresAt = nil } if let expiresAt { entitlementExpirationTask = Task { [weak self] in let remaining = min(60, max(0, expiresAt.timeIntervalSince(self?.now() ?? .now))) try? await Task.sleep(for: .seconds(remaining)) guard !Task.isCancelled else { return } await self?.refresh() } } } private static func dateComponents(for period: Product.SubscriptionPeriod, count: Int) -> DateComponents { let value = period.value * count var components = DateComponents() switch period.unit { case .day: components.day = value case .week: components.weekOfYear = value case .month: components.month = value case .year: components.year = value @unknown default: break } return components } } public nonisolated enum ProAccessControllerError: LocalizedError, Equatable, Sendable { case productUnavailable case productMetadataUnavailable case purchaseVerificationFailed case purchasePending case operationFailed(String) case purchaseNotFound public var errorDescription: String? { switch self { case .productUnavailable: String(localized: "Termind Pro is not available from the App Store right now.") case .productMetadataUnavailable: String(localized: "The App Store is temporarily unavailable. You can keep using already unlocked features.") case .operationFailed(let description): description case .purchaseVerificationFailed: String(localized: "The App Store could not verify this purchase.") case .purchasePending: String(localized: "The purchase is waiting for App Store approval.") case .purchaseNotFound: String(localized: "No active Termind Pro purchase or subscription was found for this App Store account.") } } }