| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169 |
- //
- // SubscriptionStore.swift
- // App for Indeed
- //
- import Foundation
- import StoreKit
- extension Notification.Name {
- static let subscriptionStatusDidChange = Notification.Name("subscriptionStatusDidChange")
- }
- @MainActor
- final class SubscriptionStore {
- static let shared = SubscriptionStore()
- private(set) var productsByID: [String: Product] = [:]
- /// Mirrors StoreKit entitlements; safe to read on the main thread after `refreshEntitlements()` or a `.subscriptionStatusDidChange` notification.
- private(set) var isProActive: Bool = false
- private var transactionListenerTask: Task<Void, Never>?
- private init() {
- transactionListenerTask = Task { await listenForTransactions() }
- }
- deinit {
- transactionListenerTask?.cancel()
- }
- /// Syncs `isProActive` with StoreKit. Set `deep` to also consult subscription status via `Product.products` when entitlements are empty (after sync, restore, or opening the paywall).
- /// Posts `.subscriptionStatusDidChange` when the value changes.
- func refreshEntitlements(deep: Bool = false) async {
- let before = isProActive
- isProActive = await computeProEntitlementFromStore()
- if !isProActive, deep {
- isProActive = await computeProFromLoadedSubscriptionProducts()
- }
- if before != isProActive {
- NotificationCenter.default.post(name: .subscriptionStatusDidChange, object: nil)
- }
- }
- /// Loads subscription products from the App Store (or StoreKit Testing in Xcode).
- func loadProducts() async {
- do {
- let products = try await Product.products(for: SubscriptionProductIDs.all)
- var map: [String: Product] = [:]
- for product in products {
- map[product.id] = product
- }
- productsByID = map
- } catch {
- productsByID = [:]
- }
- }
- func product(forPlanKey planKey: String) -> Product? {
- guard let id = SubscriptionProductIDs.productID(planKey: planKey) else { return nil }
- return productsByID[id]
- }
- func purchase(planKey: String) async throws -> Bool {
- guard let product = product(forPlanKey: planKey) else {
- throw SubscriptionStoreError.productUnavailable
- }
- let result = try await product.purchase()
- switch result {
- case .success(let verification):
- let transaction = try checkVerified(verification)
- await transaction.finish()
- await refreshEntitlements(deep: true)
- return true
- case .userCancelled:
- return false
- case .pending:
- return false
- @unknown default:
- return false
- }
- }
- /// Restores purchases by syncing with the App Store (required on macOS instead of `restoreCompletedTransactions`).
- func restorePurchases() async throws {
- try await AppStore.sync()
- await refreshEntitlements(deep: true)
- }
- /// Whether the user has an active subscription for one of this app’s Pro product IDs.
- func hasActiveSubscription() async -> Bool {
- await refreshEntitlements(deep: true)
- return isProActive
- }
- private func computeProEntitlementFromStore() async -> Bool {
- for await result in Transaction.currentEntitlements {
- guard case .verified(let transaction) = result else { continue }
- if transaction.revocationDate != nil { continue }
- if SubscriptionProductIDs.all.contains(transaction.productID) {
- return true
- }
- }
- return false
- }
- /// Fallback when `currentEntitlements` is empty or lagging (common right after install, account changes, or macOS StoreKit edge cases).
- private func computeProFromLoadedSubscriptionProducts() async -> Bool {
- do {
- let products = try await Product.products(for: SubscriptionProductIDs.all)
- for product in products {
- guard let subscription = product.subscription else { continue }
- let statuses = try await subscription.status
- for status in statuses {
- switch status.state {
- case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
- return true
- case .expired, .revoked:
- break
- default:
- break
- }
- }
- }
- } catch {
- return false
- }
- return false
- }
- private func listenForTransactions() async {
- for await result in Transaction.updates {
- guard case .verified(let transaction) = result else { continue }
- await transaction.finish()
- await refreshEntitlements(deep: true)
- }
- }
- private nonisolated func checkVerified(_ result: VerificationResult<Transaction>) throws -> Transaction {
- switch result {
- case .unverified(_, let error):
- throw error
- case .verified(let transaction):
- return transaction
- }
- }
- }
- enum SubscriptionStoreError: LocalizedError {
- /// No `Product` was returned for this ID (wrong IDs, products not approved in ASC, or StoreKit Configuration not selected in the scheme).
- case productUnavailable
- var errorDescription: String? {
- switch self {
- case .productUnavailable:
- return "That subscription isn’t available from the App Store right now."
- }
- }
- var recoverySuggestion: String? {
- switch self {
- case .productUnavailable:
- return """
- For local testing in Xcode: Product → Scheme → Edit Scheme → Run → Options → StoreKit Configuration → choose Paywall.storekit.
- For TestFlight / App Store: In App Store Connect, create auto-renewable subscriptions whose Product IDs exactly match SubscriptionProductIDs.swift (same spelling as com.mqldev.appforindeed.pro.*), then submit them with the app version.
- """
- }
- }
- }
|