Parcourir la source

Fix subscription state to persist across relaunch and lapse on downgrade.

Use StoreKit as the source of truth with a verified entitlement cache, authoritative refresh on lifecycle events, and proper clearing when subscriptions are revoked, expired, or deleted.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 il y a 2 semaines
Parent
commit
94a585c1ee

+ 324 - 101
gramora/Managers/SubscriptionManager.swift

@@ -62,9 +62,9 @@ final class SubscriptionManager: ObservableObject {
         max(0, Self.maxFreeAIUses - freeAIUsesConsumed)
     }
 
-    /// Premium access confirmed by StoreKit after launch or purchase.
+    /// Premium access confirmed by StoreKit or a short post-purchase grace window.
     var isPremiumActive: Bool {
-        hasResolvedPremiumStatus && hasPremiumAccess
+        hasPremiumAccess
     }
 
     var canUseAIFeatures: Bool {
@@ -72,25 +72,133 @@ final class SubscriptionManager: ObservableObject {
     }
 
     private var inFlightProductLoad: Task<Void, Never>?
+    private var launchEntitlementTask: Task<Void, Never>?
+    private var entitlementRefreshTask: Task<Void, Never>?
+    private var isLaunchEntitlementRefreshInProgress = false
     private var cancellables = Set<AnyCancellable>()
     private let defaults: UserDefaults
+    private let entitlementCache: SubscriptionEntitlementCache
     /// Keeps premium UI in sync after a verified purchase until StoreKit entitlements catch up.
     private var pendingVerifiedPurchase: (plan: PaywallPlan, purchasedAt: Date)?
+    private enum PremiumRefreshMode {
+        /// StoreKit is the source of truth; lapse premium when nothing is active.
+        case authoritative
+        /// During launch only: may replay verified cache while StoreKit syncs.
+        case optimistic
+    }
+
     private static let pendingPurchaseGraceInterval: TimeInterval = 24 * 60 * 60
 
-    init(defaults: UserDefaults = .standard) {
+    init(
+        defaults: UserDefaults = .standard,
+        entitlementCache: SubscriptionEntitlementCache = SubscriptionEntitlementCache()
+    ) {
         self.defaults = defaults
+        self.entitlementCache = entitlementCache
         freeAIUsesConsumed = min(
             defaults.integer(forKey: Self.freeAIUsesConsumedKey),
             Self.maxFreeAIUses
         )
+        applyVerifiedCacheIfAvailable(markResolved: true)
+        restorePersistedPendingPurchase()
 
         observeAIFeatureUsage()
 
+        launchEntitlementTask = Task { await refreshPremiumAccessOnLaunch() }
         Task { await listenForTransactionUpdates() }
-        Task { await refreshPremiumAccess() }
         Task { await loadProducts(presentLoadingUI: false) }
         observeAppLifecycle()
+        observeAppTermination()
+    }
+
+    private func observeAppTermination() {
+        NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
+            .sink { [weak self] _ in
+                self?.persistPremiumStateBeforeTermination()
+            }
+            .store(in: &cancellables)
+    }
+
+    private func persistPremiumStateBeforeTermination() {
+        guard isPremiumActive,
+              let plan = activeRecurringPlan ?? activePlan,
+              let expiration = entitlementCache.loadVerified()?.expiration else { return }
+
+        entitlementCache.saveVerified(plan: plan, expiration: expiration)
+    }
+
+    @discardableResult
+    private func applyVerifiedCacheIfAvailable(markResolved: Bool) -> Bool {
+        guard let stored = entitlementCache.loadVerified(), stored.isValid else { return false }
+
+        hasPremiumAccess = true
+        hasManageableSubscription = true
+        if let plan = stored.paywallPlan {
+            activePlan = plan
+            activeRecurringPlan = plan
+        }
+        if markResolved {
+            hasResolvedPremiumStatus = true
+        }
+        return true
+    }
+
+    private func activatePremium(
+        plan: PaywallPlan?,
+        expiration: Date?,
+        persist: Bool
+    ) {
+        hasPremiumAccess = true
+        hasManageableSubscription = true
+        activePlan = plan
+        activeRecurringPlan = plan
+        if persist, let plan, let expiration, expiration > Date() {
+            entitlementCache.saveVerified(plan: plan, expiration: expiration)
+        }
+    }
+
+    private func isPendingPurchaseGraceActive() -> Bool {
+        guard let pending = pendingVerifiedPurchase else { return false }
+        return Date().timeIntervalSince(pending.purchasedAt) < Self.pendingPurchaseGraceInterval
+    }
+
+    private func applyPendingPurchaseGraceIfNeeded() -> Bool {
+        guard isPendingPurchaseGraceActive(), let pending = pendingVerifiedPurchase else { return false }
+
+        let fallbackExpiration = Date().addingTimeInterval(Self.pendingPurchaseGraceInterval)
+        activatePremium(plan: pending.plan, expiration: fallbackExpiration, persist: false)
+        return true
+    }
+
+    private func restorePersistedPendingPurchase() {
+        guard let pending = entitlementCache.loadPendingPurchase() else { return }
+
+        let age = Date().timeIntervalSince(pending.purchasedAt)
+        guard age < Self.pendingPurchaseGraceInterval else {
+            entitlementCache.clearPendingPurchase()
+            return
+        }
+
+        pendingVerifiedPurchase = pending
+        if !isPremiumActive {
+            applyPendingPurchaseGraceIfNeeded()
+            hasResolvedPremiumStatus = true
+        }
+    }
+
+    private func clearPersistedPendingPurchase() {
+        pendingVerifiedPurchase = nil
+        entitlementCache.clearPendingPurchase()
+    }
+
+    private func deactivatePremium(clearStoredEntitlement: Bool) {
+        hasPremiumAccess = false
+        hasManageableSubscription = false
+        activePlan = nil
+        activeRecurringPlan = nil
+        if clearStoredEntitlement {
+            entitlementCache.clear()
+        }
     }
 
     func recordSuccessfulAIUse() {
@@ -111,6 +219,8 @@ final class SubscriptionManager: ObservableObject {
     }
 
     func loadProducts(presentLoadingUI: Bool? = nil) async {
+        await launchEntitlementTask?.value
+
         if let existing = inFlightProductLoad {
             await existing.value
             return
@@ -280,15 +390,15 @@ final class SubscriptionManager: ObservableObject {
                     purchaseError = .purchaseNotVerified
                     return false
                 }
-                await transaction.finish()
                 applyOptimisticPremiumAccess(for: transaction)
+                await transaction.finish()
                 await refreshPremiumAccessWithRetry()
-                if hasPremiumAccess {
+                if isPremiumActive {
                     NotificationCenter.default.post(name: .subscriptionPurchased, object: nil)
                 } else {
                     subscriptionSuccessMessage = "Purchase successful. If premium features don't unlock right away, tap Restore Purchases."
                 }
-                return hasPremiumAccess
+                return isPremiumActive
             case .userCancelled:
                 return false
             case .pending:
@@ -315,8 +425,8 @@ final class SubscriptionManager: ObservableObject {
         let hadPremium = isPremiumActive
         do {
             try await AppStore.sync()
-            await refreshPremiumAccess()
-            if hasPremiumAccess {
+            await enqueuePremiumRefresh(mode: .authoritative)
+            if isPremiumActive {
                 if !hadPremium {
                     subscriptionSuccessMessage = "Your purchases have been restored."
                 }
@@ -339,7 +449,7 @@ final class SubscriptionManager: ObservableObject {
         if !missingAny, presentLoadingUI != true {
             purchaseError = nil
             await refreshIntroOfferEligibility()
-            await refreshPremiumAccess()
+            await enqueuePremiumRefresh()
             return
         }
 
@@ -361,7 +471,7 @@ final class SubscriptionManager: ObservableObject {
             }
             productsByID = map
             await refreshIntroOfferEligibility()
-            await refreshPremiumAccess()
+            await enqueuePremiumRefresh()
         } catch is ProductLoadError {
             if presentLoadingUI == true {
                 purchaseError = .productsLoadTimeout
@@ -392,56 +502,189 @@ final class SubscriptionManager: ObservableObject {
         }
     }
 
-    private func refreshPremiumAccess() async {
+    private func enqueuePremiumRefresh(mode: PremiumRefreshMode = .authoritative) async {
+        if let existing = entitlementRefreshTask {
+            await existing.value
+        }
+
+        let task = Task { @MainActor in
+            await performPremiumRefresh(mode: mode)
+        }
+        entitlementRefreshTask = task
+        await task.value
+        entitlementRefreshTask = nil
+    }
+
+    private struct ResolvedEntitlement {
         var hasPremium = false
         var manageableSubscription = false
-        var resolvedPlan: PaywallPlan?
-        var resolvedRecurringPlan: PaywallPlan?
-        var revokedPendingPlan = false
+        var plan: PaywallPlan?
+        var recurringPlan: PaywallPlan?
+        var expiration: Date?
+        var storeKitConfirmedInactive = false
+    }
+
+    private func performPremiumRefresh(mode: PremiumRefreshMode) async {
+        var resolved = await resolveEntitlementsFromStoreKit()
+
+        if resolved.hasPremium {
+            clearPersistedPendingPurchase()
+            activatePremium(
+                plan: resolved.recurringPlan ?? resolved.plan,
+                expiration: resolved.expiration,
+                persist: true
+            )
+            hasResolvedPremiumStatus = true
+            return
+        }
 
-        for await result in Transaction.currentEntitlements {
-            guard case .verified(let transaction) = result else { continue }
-            guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
+        if applyPendingPurchaseGraceIfNeeded() {
+            hasResolvedPremiumStatus = true
+            return
+        }
 
-            if transaction.revocationDate != nil {
-                if pendingVerifiedPurchase?.plan == SubscriptionProductID.plan(for: transaction.productID) {
-                    revokedPendingPlan = true
-                    pendingVerifiedPurchase = nil
-                }
+        if mode == .optimistic, applyVerifiedCacheIfAvailable(markResolved: false) {
+            hasResolvedPremiumStatus = true
+            return
+        }
+
+        if resolved.storeKitConfirmedInactive || mode == .authoritative {
+            deactivatePremium(clearStoredEntitlement: true)
+        } else {
+            hasPremiumAccess = false
+            hasManageableSubscription = false
+            activePlan = nil
+            activeRecurringPlan = nil
+        }
+
+        hasResolvedPremiumStatus = true
+    }
+
+    private func resolveEntitlementsFromStoreKit() async -> ResolvedEntitlement {
+        let current = await scanEntitlements(in: Transaction.currentEntitlements)
+        if current.hasPremium {
+            return current
+        }
+
+        let fromStatus = await resolveFromSubscriptionStatuses()
+        if fromStatus.hasPremium {
+            return fromStatus
+        }
+
+        var resolved = fromStatus
+        resolved.storeKitConfirmedInactive = fromStatus.storeKitConfirmedInactive
+            || (!fromStatus.hasPremium && hasAllProductsLoaded)
+        return resolved
+    }
+
+    private func resolveFromSubscriptionStatuses() async -> ResolvedEntitlement {
+        var resolved = ResolvedEntitlement()
+        var sawAnyStatus = false
+        var sawInactiveStatus = false
+
+        for plan in PaywallPlan.allCases {
+            guard let product = productsByID[plan.productID],
+                  let subscription = product.subscription else { continue }
+
+            let statuses: [Product.SubscriptionInfo.Status]
+            do {
+                statuses = try await subscription.status
+            } catch {
                 continue
             }
 
+            for status in statuses {
+                sawAnyStatus = true
+                guard case .verified(let transaction) = status.transaction else { continue }
+
+                switch status.state {
+                case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
+                    resolved.hasPremium = true
+                    resolved.manageableSubscription = true
+                    resolved.plan = plan
+                    resolved.recurringPlan = plan
+
+                    if let expiration = transaction.expirationDate {
+                        resolved.expiration = latestExpiration(
+                            current: resolved.expiration,
+                            candidate: expiration
+                        )
+                    } else if case .verified(let renewalInfo) = status.renewalInfo {
+                        resolved.expiration = latestExpiration(
+                            current: resolved.expiration,
+                            candidate: renewalInfo.renewalDate
+                        )
+                    }
+                case .expired, .revoked:
+                    sawInactiveStatus = true
+                default:
+                    break
+                }
+            }
+        }
+
+        if !resolved.hasPremium, sawAnyStatus, sawInactiveStatus {
+            resolved.storeKitConfirmedInactive = true
+        }
+
+        return resolved
+    }
+
+    private func scanEntitlements(in transactions: Transaction.Transactions) async -> ResolvedEntitlement {
+        var resolved = ResolvedEntitlement()
+        var bestMatch: (plan: PaywallPlan, expiration: Date)?
+
+        for await result in transactions {
+            guard case .verified(let transaction) = result else { continue }
+            guard SubscriptionProductID.all.contains(transaction.productID) else { continue }
+            guard transaction.revocationDate == nil else { continue }
+
             guard let plan = SubscriptionProductID.plan(for: transaction.productID) else { continue }
-            guard await isRecurringSubscriptionEntitled(transaction: transaction) else {
+            guard isTransactionEntitled(transaction) else { continue }
+
+            let expiration = transaction.expirationDate ?? .distantFuture
+            if let bestMatch, bestMatch.expiration >= expiration {
                 continue
             }
 
-            manageableSubscription = true
-            hasPremium = true
+            bestMatch = (plan, expiration)
+            resolved.hasPremium = true
+            resolved.manageableSubscription = true
             pendingVerifiedPurchase = nil
-            if resolvedRecurringPlan == nil {
-                resolvedRecurringPlan = plan
-            }
-            if resolvedPlan == nil {
-                resolvedPlan = plan
-            }
+            resolved.expiration = latestExpiration(current: resolved.expiration, candidate: transaction.expirationDate)
+            resolved.recurringPlan = plan
+            resolved.plan = plan
         }
 
-        if !hasPremium,
-           !revokedPendingPlan,
-           let pending = pendingVerifiedPurchase,
-           Date().timeIntervalSince(pending.purchasedAt) < Self.pendingPurchaseGraceInterval {
-            hasPremium = true
-            manageableSubscription = true
-            resolvedPlan = pending.plan
-            resolvedRecurringPlan = pending.plan
+        return resolved
+    }
+
+    private func refreshPremiumAccess(mode: PremiumRefreshMode = .authoritative) async {
+        await enqueuePremiumRefresh(mode: mode)
+    }
+
+    private func latestExpiration(current: Date?, candidate: Date?) -> Date? {
+        switch (current, candidate) {
+        case let (current?, candidate?) where candidate > current:
+            candidate
+        case let (current?, _):
+            current
+        case (nil, let candidate?):
+            candidate
+        case (nil, nil):
+            nil
         }
+    }
 
-        hasPremiumAccess = hasPremium
-        hasManageableSubscription = manageableSubscription
-        activePlan = resolvedPlan
-        activeRecurringPlan = resolvedRecurringPlan
-        hasResolvedPremiumStatus = true
+    private func isTransactionEntitled(_ transaction: Transaction) -> Bool {
+        guard transaction.revocationDate == nil else { return false }
+
+        if let expirationDate = transaction.expirationDate {
+            return expirationDate > Date()
+        }
+
+        // Verified, non-revoked current entitlements without an expiration are active.
+        return true
     }
 
     private func applyOptimisticPremiumAccess(for transaction: Transaction) {
@@ -450,11 +693,10 @@ final class SubscriptionManager: ObservableObject {
               let plan = SubscriptionProductID.plan(for: transaction.productID) else { return }
 
         pendingVerifiedPurchase = (plan: plan, purchasedAt: Date())
-        hasPremiumAccess = true
         hasResolvedPremiumStatus = true
-        activePlan = plan
-        hasManageableSubscription = true
-        activeRecurringPlan = plan
+        entitlementCache.savePendingPurchase(plan: plan)
+        let expiration = transaction.expirationDate ?? Date().addingTimeInterval(30 * 24 * 60 * 60)
+        activatePremium(plan: plan, expiration: expiration, persist: true)
     }
 
     private func refreshIntroOfferEligibility() async {
@@ -472,74 +714,56 @@ final class SubscriptionManager: ObservableObject {
         introOfferEligibleByPlan = eligibility
     }
 
-    private func refreshPremiumAccessWithRetry() async {
-        await refreshPremiumAccess()
-        guard !hasPremiumAccess else { return }
+    private func refreshPremiumAccessWithRetry(mode: PremiumRefreshMode = .authoritative) async {
+        await refreshPremiumAccess(mode: mode)
+        guard !isPremiumActive else { return }
 
         for _ in 0..<6 {
             try? await Task.sleep(nanoseconds: 500_000_000)
-            await refreshPremiumAccess()
-            if hasPremiumAccess { return }
+            await refreshPremiumAccess(mode: mode)
+            if isPremiumActive { return }
         }
 
-        if let pending = pendingVerifiedPurchase,
-           Date().timeIntervalSince(pending.purchasedAt) < Self.pendingPurchaseGraceInterval {
-            hasPremiumAccess = true
+        if applyPendingPurchaseGraceIfNeeded() {
             hasResolvedPremiumStatus = true
-            hasManageableSubscription = true
-            activePlan = pending.plan
-            activeRecurringPlan = pending.plan
         }
     }
 
-    private func isRecurringSubscriptionEntitled(transaction: Transaction) async -> Bool {
-        guard transaction.revocationDate == nil else { return false }
+    private func refreshPremiumAccessOnLaunch() async {
+        isLaunchEntitlementRefreshInProgress = true
+        defer { isLaunchEntitlementRefreshInProgress = false }
 
-        let productID = transaction.productID
-        let product: Product?
-        if let cached = productsByID[productID] {
-            product = cached
-        } else if let loaded = try? await Product.products(for: [productID]).first {
-            product = loaded
-        } else {
-            product = nil
-        }
+        applyVerifiedCacheIfAvailable(markResolved: true)
 
-        if let product, let subscription = product.subscription {
-            do {
-                let statuses = try await subscription.status
-                if statuses.contains(where: {
-                    switch $0.state {
-                    case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
-                        return true
-                    case .expired, .revoked:
-                        return false
-                    default:
-                        return false
-                    }
-                }) {
-                    return true
-                }
-                if statuses.contains(where: { $0.state == .expired || $0.state == .revoked }) {
-                    return false
-                }
-            } catch {
-                // Fall through to expiration-date check when status is unavailable.
-            }
+        for _ in 0..<6 where !isPremiumActive {
+            await enqueuePremiumRefresh(mode: .optimistic)
+            if isPremiumActive { break }
+            try? await Task.sleep(nanoseconds: 500_000_000)
         }
 
-        // Verified entitlement with a future expiration is still active when metadata/status is unreachable.
-        if let expirationDate = transaction.expirationDate {
-            return expirationDate > Date()
+        await enqueuePremiumRefresh(mode: .authoritative)
+
+        if !hasResolvedPremiumStatus {
+            hasResolvedPremiumStatus = true
         }
 
-        return false
+        #if DEBUG
+        let stored = entitlementCache.loadVerified()
+        print(
+            """
+            [SubscriptionManager] Launch entitlement resolved: \
+            premium=\(isPremiumActive), stored=\(stored?.isValid == true), \
+            plan=\(activePlan?.rawValue ?? "none")
+            """
+        )
+        #endif
     }
 
     private func observeAppLifecycle() {
         NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)
             .sink { [weak self] _ in
-                Task { await self?.refreshPremiumAccess() }
+                guard let self, !self.isLaunchEntitlementRefreshInProgress else { return }
+                Task { await self.enqueuePremiumRefresh(mode: .authoritative) }
             }
             .store(in: &cancellables)
     }
@@ -548,13 +772,12 @@ final class SubscriptionManager: ObservableObject {
         for await update in Transaction.updates {
             switch update {
             case .verified(let transaction):
-                if transaction.revocationDate != nil,
-                   let plan = SubscriptionProductID.plan(for: transaction.productID),
-                   pendingVerifiedPurchase?.plan == plan {
+                if SubscriptionProductID.all.contains(transaction.productID),
+                   transaction.revocationDate != nil {
                     pendingVerifiedPurchase = nil
                 }
                 await transaction.finish()
-                await refreshPremiumAccess()
+                await enqueuePremiumRefresh(mode: .authoritative)
             case .unverified(_, let error):
                 #if DEBUG
                 print("[SubscriptionManager] Unverified transaction update: \(error)")

+ 138 - 0
gramora/Services/SubscriptionEntitlementCache.swift

@@ -0,0 +1,138 @@
+import Foundation
+
+struct StoredSubscriptionEntitlement: Codable, Equatable {
+    var isActive: Bool
+    var plan: String?
+    var expiration: Date?
+    var lastVerifiedAt: Date
+
+    var isValid: Bool {
+        guard isActive else { return false }
+        if let expiration {
+            return expiration > Date()
+        }
+        return false
+    }
+
+    var paywallPlan: PaywallPlan? {
+        guard let plan else { return nil }
+        return PaywallPlan(rawValue: plan)
+    }
+}
+
+/// Persists StoreKit-confirmed subscription state across relaunches.
+/// Does not grant premium on its own — only replays verified entitlements.
+final class SubscriptionEntitlementCache {
+    private let fileURL: URL
+    private let defaults: UserDefaults
+    private let fileManager: FileManager
+
+    private static let legacyPremiumKey = "subscription.cachedPremiumAccess"
+    private static let legacyPlanKey = "subscription.cachedActivePlan"
+    private static let legacyExpirationKey = "subscription.cachedExpiration"
+    private static let pendingPurchasePlanKey = "subscription.pendingPurchasePlan"
+    private static let pendingPurchaseDateKey = "subscription.pendingPurchaseDate"
+
+    init(
+        fileManager: FileManager = .default,
+        defaults: UserDefaults = .standard
+    ) {
+        self.fileManager = fileManager
+        self.defaults = defaults
+
+        let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+        let directory = appSupport.appendingPathComponent("GrammarPro", isDirectory: true)
+        try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
+        fileURL = directory.appendingPathComponent("subscription-entitlement.json")
+    }
+
+    /// Loads only verified cache entries that include a real expiration date.
+    func loadVerified() -> StoredSubscriptionEntitlement? {
+        if let stored = loadFromFile(), stored.isValid {
+            return stored
+        }
+        return loadLegacyDefaults()
+    }
+
+    func saveVerified(plan: PaywallPlan, expiration: Date) {
+        guard expiration > Date() else {
+            clear()
+            return
+        }
+
+        let stored = StoredSubscriptionEntitlement(
+            isActive: true,
+            plan: plan.rawValue,
+            expiration: expiration,
+            lastVerifiedAt: Date()
+        )
+
+        if let data = try? JSONEncoder().encode(stored) {
+            try? data.write(to: fileURL, options: [.atomic])
+        }
+
+        defaults.set(true, forKey: Self.legacyPremiumKey)
+        defaults.set(plan.rawValue, forKey: Self.legacyPlanKey)
+        defaults.set(expiration.timeIntervalSince1970, forKey: Self.legacyExpirationKey)
+    }
+
+    func savePendingPurchase(plan: PaywallPlan, purchasedAt: Date = Date()) {
+        defaults.set(plan.rawValue, forKey: Self.pendingPurchasePlanKey)
+        defaults.set(purchasedAt, forKey: Self.pendingPurchaseDateKey)
+    }
+
+    func loadPendingPurchase() -> (plan: PaywallPlan, purchasedAt: Date)? {
+        guard
+            let planRaw = defaults.string(forKey: Self.pendingPurchasePlanKey),
+            let plan = PaywallPlan(rawValue: planRaw),
+            let purchasedAt = defaults.object(forKey: Self.pendingPurchaseDateKey) as? Date
+        else {
+            return nil
+        }
+        return (plan, purchasedAt)
+    }
+
+    func clearPendingPurchase() {
+        defaults.removeObject(forKey: Self.pendingPurchasePlanKey)
+        defaults.removeObject(forKey: Self.pendingPurchaseDateKey)
+    }
+
+    func clear() {
+        try? fileManager.removeItem(at: fileURL)
+        defaults.set(false, forKey: Self.legacyPremiumKey)
+        defaults.removeObject(forKey: Self.legacyPlanKey)
+        defaults.removeObject(forKey: Self.legacyExpirationKey)
+        clearPendingPurchase()
+        defaults.removeObject(forKey: "lastKnownPremiumAccess")
+    }
+
+    private func loadFromFile() -> StoredSubscriptionEntitlement? {
+        guard let data = try? Data(contentsOf: fileURL) else { return nil }
+        return try? JSONDecoder().decode(StoredSubscriptionEntitlement.self, from: data)
+    }
+
+    private func loadLegacyDefaults() -> StoredSubscriptionEntitlement? {
+        guard defaults.bool(forKey: Self.legacyPremiumKey) else { return nil }
+
+        let expirationTimestamp = defaults.double(forKey: Self.legacyExpirationKey)
+        guard expirationTimestamp > 0 else { return nil }
+
+        let expiration = Date(timeIntervalSince1970: expirationTimestamp)
+        let stored = StoredSubscriptionEntitlement(
+            isActive: true,
+            plan: defaults.string(forKey: Self.legacyPlanKey),
+            expiration: expiration,
+            lastVerifiedAt: Date()
+        )
+
+        guard stored.isValid else {
+            clear()
+            return nil
+        }
+
+        if let plan = stored.paywallPlan {
+            saveVerified(plan: plan, expiration: expiration)
+        }
+        return stored
+    }
+}