Ver código fonte

Centralize StoreKit product IDs in paywall config and extract StoreManager.

Add bundled productID fields to paywall.json aligned with Products.storekit, load plans from StoreKit at runtime, and move store/premium logic out of PaywallView.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 3 semanas atrás
pai
commit
fdae939930

+ 5 - 5
smart_printer/AppStoreConfig.swift

@@ -25,11 +25,11 @@ enum AppStoreConfig {
 
     enum ProductID {
         // ASC allows only alphanumeric characters, underscores, and periods.
-        private static let prefix = "com.printer_app_all_printers"
-        static let monthly = "\(prefix).premium.monthly"
-        static let yearly = "\(prefix).premium.yearly"
-        static let lifetime = "\(prefix).premium.lifetime"
+        // Keep aligned with `Products.storekit` and bundled `paywall.json` plan productIDs.
+        static let monthly = PaywallConfig.loadBundled().plans.monthly.productID
+        static let yearly = PaywallConfig.loadBundled().plans.yearly.productID
+        static let lifetime = PaywallConfig.loadBundled().plans.lifetime.productID
 
-        static let all: Set<String> = [monthly, yearly, lifetime]
+        static let all: Set<String> = PaywallConfig.loadBundled().allProductIDs
     }
 }

+ 736 - 0
smart_printer/Managers/StoreManager.swift

@@ -0,0 +1,736 @@
+import Cocoa
+import StoreKit
+
+enum PremiumAccessKind: String {
+    case none
+    case subscription
+    case lifetime
+}
+
+enum StoreError: Error {
+    case productNotFound
+    case failedVerification
+}
+
+enum PurchaseOutcome: Sendable {
+    case purchased
+    case cancelled
+    case pending
+}
+
+@MainActor
+final class StoreManager {
+    static let shared = StoreManager()
+
+    private enum ProductLoadError: Error {
+        case timeout
+    }
+
+    private enum StoreSyncError: Error {
+        case timeout
+    }
+
+    private static let cachedPremiumKey = "StoreManager.cachedIsPremium"
+    private static let cachedPremiumPlanKey = "StoreManager.cachedPremiumPlan"
+    private static let cachedHasEverPurchasedKey = "StoreManager.cachedHasEverPurchased"
+    private static let backgroundRefreshInterval: TimeInterval = 120
+
+    private var productCatalog: PaywallConfig { PaywallConfig.loadBundled() }
+
+    private(set) var productsByID: [String: Product] = [:]
+    private(set) var isPremium = UserDefaults.standard.bool(forKey: cachedPremiumKey)
+    private(set) var activePremiumPlan: PaywallPlan?
+    private(set) var hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: cachedHasEverPurchasedKey)
+    private(set) var trialDisplayByPlan: [PaywallPlan: PaywallTrialDisplay] = [:]
+    private(set) var purchaseError: PurchaseError?
+
+    var isPro: Bool { isPremium }
+    var hasActiveSubscription: Bool { activePremiumPlan == .monthly || activePremiumPlan == .yearly }
+    var hasLifetimeAccess: Bool { activePremiumPlan == .lifetime }
+    var premiumAccessKind: PremiumAccessKind {
+        switch activePremiumPlan {
+        case .lifetime: .lifetime
+        case .monthly, .yearly: .subscription
+        case nil: .none
+        }
+    }
+
+    var products: [Product] {
+        availablePlans.compactMap { productsByID[$0.productID] }
+    }
+
+    var availablePlans: [PaywallPlan] {
+        PaywallPlan.displayOrder.filter { productsByID[$0.productID] != nil }
+    }
+
+    var isResolvingEntitlements: Bool { !hasResolvedPremiumStatus }
+    var hasResolvedPremiumStatus = false
+    var hasAvailablePlans: Bool { !availablePlans.isEmpty }
+    var hasAllProductsLoaded: Bool { hasAvailablePlans }
+
+    private(set) var isLoadingProducts = false
+    private(set) var productLoadError: String?
+    private(set) var purchasingPlan: PaywallPlan?
+    private(set) var isRestoringPurchases = false
+
+    var isPurchasing: Bool { purchasingPlan != nil || isRestoringPurchases }
+
+    private var inFlightProductLoad: Task<Void, Never>?
+    private var transactionListener: Task<Void, Never>?
+    private var initialEntitlementTask: Task<Void, Never>?
+    private var entitlementRefreshTask: Task<Void, Never>?
+    private var hasStarted = false
+    private var lastBackgroundEntitlementRefresh: Date?
+    private var appActiveObserver: NSObjectProtocol?
+
+    private init() {
+        migrateLegacyPremiumCacheIfNeeded()
+        hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: Self.cachedHasEverPurchasedKey)
+
+        if UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) {
+            isPremium = true
+            activePremiumPlan = restoredCachedPremiumPlan()
+        }
+    }
+
+    private func migrateLegacyPremiumCacheIfNeeded() {
+        guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return }
+        guard UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey) == nil,
+              let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") else {
+            return
+        }
+
+        let migratedPlan: String?
+        switch legacyKind {
+        case PremiumAccessKind.lifetime.rawValue:
+            migratedPlan = PaywallPlan.lifetime.rawValue
+        case PremiumAccessKind.subscription.rawValue:
+            migratedPlan = PaywallPlan.yearly.rawValue
+        default:
+            migratedPlan = nil
+        }
+
+        if let migratedPlan {
+            UserDefaults.standard.set(migratedPlan, forKey: Self.cachedPremiumPlanKey)
+        }
+    }
+
+    private func restoredCachedPremiumPlan() -> PaywallPlan? {
+        guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return nil }
+
+        if let raw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
+           let plan = PaywallPlan(rawValue: raw) {
+            return plan
+        }
+
+        if let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") {
+            switch legacyKind {
+            case PremiumAccessKind.lifetime.rawValue:
+                return .lifetime
+            case PremiumAccessKind.subscription.rawValue:
+                return .yearly
+            default:
+                break
+            }
+        }
+
+        return .yearly
+    }
+
+    func start() {
+        guard !hasStarted else { return }
+        hasStarted = true
+
+        observeAppLifecycle()
+        transactionListener = Task { [weak self] in
+            await self?.listenForTransactionUpdates()
+        }
+
+        initialEntitlementTask = Task { [weak self] in
+            guard let self else { return }
+            await self.refreshPremiumAccess()
+            await self.loadProducts(presentLoadingUI: false)
+        }
+    }
+
+    private func observeAppLifecycle() {
+        appActiveObserver = NotificationCenter.default.addObserver(
+            forName: NSApplication.didBecomeActiveNotification,
+            object: nil,
+            queue: .main
+        ) { [weak self] _ in
+            Task { @MainActor in
+                self?.refreshEntitlementsOnAppActive()
+            }
+        }
+    }
+
+    private func refreshEntitlementsOnAppActive() {
+        guard hasStarted else { return }
+
+        if let lastRefresh = lastBackgroundEntitlementRefresh,
+           Date().timeIntervalSince(lastRefresh) < Self.backgroundRefreshInterval {
+            return
+        }
+
+        entitlementRefreshTask?.cancel()
+        entitlementRefreshTask = Task {
+            await refreshPremiumAccess()
+            lastBackgroundEntitlementRefresh = Date()
+        }
+    }
+
+    func ensureEntitlementsResolved() async {
+        await initialEntitlementTask?.value
+    }
+
+    func ensurePaywallStoreDataLoaded(presentLoadingUI: Bool = true) async {
+        await ensureEntitlementsResolved()
+        await loadProducts(presentLoadingUI: presentLoadingUI && !hasAllProductsLoaded)
+        await refreshTrialOffers()
+    }
+
+    func product(for plan: PaywallPlan) -> Product? {
+        productsByID[plan.productID]
+    }
+
+    func trialDisplay(for plan: PaywallPlan) -> PaywallTrialDisplay? {
+        trialDisplayByPlan[plan]
+    }
+
+    func clearPurchaseError() {
+        purchaseError = nil
+    }
+
+    var isEligibleForIntroTrial: Bool {
+        !hasEverPurchasedPremium && !isPremium
+    }
+
+    func loadProducts(presentLoadingUI: Bool? = nil) async {
+        if let existing = inFlightProductLoad {
+            await existing.value
+            return
+        }
+
+        let uiFlag = presentLoadingUI
+        let task = Task { @MainActor in
+            await performLoadProducts(presentLoadingUI: uiFlag)
+        }
+        inFlightProductLoad = task
+        await task.value
+        inFlightProductLoad = nil
+    }
+
+    @discardableResult
+    func purchase(plan: PaywallPlan) async -> Bool {
+        purchaseError = nil
+
+        var product = productsByID[plan.productID]
+        if product == nil {
+            await loadProducts(presentLoadingUI: false)
+            product = productsByID[plan.productID]
+        }
+
+        guard let product else {
+            purchaseError = products.isEmpty ? .subscriptionUnavailable : .planUnavailable
+            return false
+        }
+
+        purchasingPlan = plan
+        postStoreStateDidChange()
+        defer {
+            purchasingPlan = nil
+            postStoreStateDidChange()
+        }
+
+        do {
+            let result = try await product.purchase()
+            switch result {
+            case .success(let verification):
+                guard case .verified(let transaction) = verification else {
+                    purchaseError = .purchaseNotVerified
+                    return false
+                }
+
+                let trustedPlan = planGrantingPremium(from: transaction)
+                if let trustedPlan {
+                    applyPremiumAccess(from: transaction)
+                }
+
+                await transaction.finish()
+                await reconcilePremiumAccess(trustedPlan: trustedPlan)
+                return isPremium
+            case .userCancelled:
+                return false
+            case .pending:
+                purchaseError = .purchasePending
+                return false
+            @unknown default:
+                return false
+            }
+        } catch {
+            purchaseError = .generic
+            return false
+        }
+    }
+
+    @discardableResult
+    func restorePurchases() async -> Bool {
+        purchaseError = nil
+        isRestoringPurchases = true
+        postStoreStateDidChange()
+        defer {
+            isRestoringPurchases = false
+            postStoreStateDidChange()
+        }
+
+        do {
+            try await syncAppStoreWithTimeout(seconds: 30)
+        } catch is StoreSyncError {
+            purchaseError = .restoreSyncTimeout
+            return false
+        } catch {
+            purchaseError = .generic
+            return false
+        }
+
+        await reconcilePremiumAccess(trustedPlan: nil)
+        await syncPurchaseHistory(allowReset: true)
+        if !isPremium {
+            purchaseError = .noActivePurchases
+        }
+        return isPremium
+    }
+
+    func showAlert(title: String, message: String, on window: NSWindow?) {
+        let alert = NSAlert()
+        alert.messageText = title
+        alert.informativeText = message
+        alert.alertStyle = .informational
+        alert.addButton(withTitle: PaywallConfigService.shared.config.messages.alertOK)
+        if let window {
+            alert.beginSheetModal(for: window)
+        } else {
+            alert.runModal()
+        }
+    }
+
+    func showManageSubscriptions() {
+        let urlString = PaywallConfigService.shared.config.urls.manageSubscriptions
+        guard let url = URL(string: urlString) else { return }
+        NSWorkspace.shared.open(url)
+    }
+
+    func showPurchaseError(_ error: PurchaseError, on window: NSWindow?) {
+        let config = PaywallConfigService.shared.config
+        showAlert(
+            title: config.messages.purchaseFailedTitle,
+            message: config.message(for: error),
+            on: window
+        )
+    }
+
+    private func performLoadProducts(presentLoadingUI: Bool?) async {
+        let shouldReload = presentLoadingUI == true || productsByID.isEmpty
+        let shouldShowLoading = presentLoadingUI ?? productsByID.isEmpty
+
+        if !shouldReload, presentLoadingUI != true {
+            purchaseError = nil
+            await refreshPremiumAccess()
+            await refreshTrialOffers()
+            postStoreStateDidChange()
+            return
+        }
+
+        if shouldShowLoading {
+            isLoadingProducts = true
+            postStoreStateDidChange()
+        }
+        defer {
+            if shouldShowLoading {
+                isLoadingProducts = false
+                postStoreStateDidChange()
+            }
+        }
+
+        purchaseError = nil
+        productLoadError = nil
+        do {
+            let loaded = try await loadProductsWithTimeout(seconds: 12)
+            guard !loaded.isEmpty else {
+                productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
+                productsByID = [:]
+                await refreshTrialOffers()
+                return
+            }
+            var map: [String: Product] = [:]
+            for product in loaded {
+                map[product.id] = product
+            }
+            productsByID = map
+            productLoadError = nil
+            NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
+            await refreshPremiumAccess()
+            await refreshTrialOffers()
+        } catch is ProductLoadError {
+            productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
+            purchaseError = .productsLoadTimeout
+            await refreshTrialOffers()
+        } catch {
+            productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
+            purchaseError = .generic
+            await refreshTrialOffers()
+        }
+    }
+
+    private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
+        let productIDs = productCatalog.allProductIDs
+        return try await withThrowingTaskGroup(of: [Product].self) { group in
+            group.addTask {
+                try await Product.products(for: productIDs)
+            }
+            group.addTask {
+                let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
+                try await Task.sleep(nanoseconds: timeoutNanoseconds)
+                throw ProductLoadError.timeout
+            }
+
+            guard let first = try await group.next() else {
+                throw ProductLoadError.timeout
+            }
+            group.cancelAll()
+            return first
+        }
+    }
+
+    private func refreshPremiumAccess(trustedPlan: PaywallPlan? = nil) async {
+        var resolvedPlan: PaywallPlan?
+
+        if let lifetimePlan = await activeLifetimePlan() {
+            resolvedPlan = lifetimePlan
+        }
+
+        if resolvedPlan == nil {
+            for await result in Transaction.currentEntitlements {
+                guard case .verified(let transaction) = result else { continue }
+                guard let plan = planGrantingPremium(from: transaction) else { continue }
+                resolvedPlan = preferredPlan(resolvedPlan, plan)
+            }
+        }
+
+        if resolvedPlan == nil || resolvedPlan != .lifetime {
+            if let subscriptionPlan = await activeSubscriptionPlan() {
+                resolvedPlan = preferredPlan(resolvedPlan, subscriptionPlan)
+            }
+        }
+
+        if resolvedPlan == nil {
+            if let statusPlan = await activePlanFromSubscriptionStatus() {
+                resolvedPlan = statusPlan
+            }
+        }
+
+        let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
+
+        applyPremiumStatus(plan: finalPlan)
+        hasResolvedPremiumStatus = true
+        NotificationCenter.default.post(name: .entitlementsDidResolve, object: nil)
+        await syncPurchaseHistory(allowReset: false)
+        await refreshTrialOffers()
+    }
+
+    private func reconcilePremiumAccess(trustedPlan: PaywallPlan?) async {
+        await refreshPremiumAccess(trustedPlan: trustedPlan)
+
+        guard let trustedPlan, !isPremium else { return }
+
+        do {
+            try await syncAppStoreWithTimeout(seconds: 15)
+        } catch {
+            applyTrustedPlan(trustedPlan)
+            return
+        }
+
+        await refreshPremiumAccess(trustedPlan: trustedPlan)
+        if !isPremium {
+            applyTrustedPlan(trustedPlan)
+        }
+    }
+
+    private func mergeTrustedPlan(_ resolvedPlan: PaywallPlan?, _ trustedPlan: PaywallPlan?) -> PaywallPlan? {
+        switch (resolvedPlan, trustedPlan) {
+        case (nil, nil):
+            nil
+        case (let resolved?, nil):
+            resolved
+        case (nil, let trusted?):
+            trusted
+        case (let resolved?, let trusted?):
+            preferredPlan(resolved, trusted)
+        }
+    }
+
+    private func applyTrustedPlan(_ plan: PaywallPlan) {
+        activePremiumPlan = preferredPlan(activePremiumPlan, plan)
+        applyPremiumStatus(plan: activePremiumPlan)
+        hasResolvedPremiumStatus = true
+        markAsHavingPurchasedPremium()
+    }
+
+    private func revokePremiumAccess() {
+        applyPremiumStatus(plan: nil)
+        hasResolvedPremiumStatus = true
+        trialDisplayByPlan = [:]
+    }
+
+    private func syncAppStoreWithTimeout(seconds: TimeInterval) async throws {
+        try await withThrowingTaskGroup(of: Void.self) { group in
+            group.addTask {
+                try await AppStore.sync()
+            }
+            group.addTask {
+                let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
+                try await Task.sleep(nanoseconds: timeoutNanoseconds)
+                throw StoreSyncError.timeout
+            }
+
+            try await group.next()
+            group.cancelAll()
+        }
+    }
+
+    private func applyPremiumAccess(from transaction: StoreKit.Transaction) {
+        guard let plan = planGrantingPremium(from: transaction) else { return }
+        activePremiumPlan = preferredPlan(activePremiumPlan, plan)
+        applyPremiumStatus(plan: activePremiumPlan)
+        hasResolvedPremiumStatus = true
+        markAsHavingPurchasedPremium()
+    }
+
+    private func markAsHavingPurchasedPremium() {
+        guard !hasEverPurchasedPremium else { return }
+        hasEverPurchasedPremium = true
+        trialDisplayByPlan = [:]
+        UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
+    }
+
+    private func syncPurchaseHistory(allowReset: Bool) async {
+        var hasRecordedPurchase = false
+        let knownProductIDs = productCatalog.allProductIDs
+
+        for await result in Transaction.all {
+            guard case .verified(let transaction) = result else { continue }
+            guard knownProductIDs.contains(transaction.productID) else { continue }
+            hasRecordedPurchase = true
+            break
+        }
+
+        if hasRecordedPurchase {
+            if !hasEverPurchasedPremium {
+                hasEverPurchasedPremium = true
+                UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
+            }
+        } else if allowReset, hasEverPurchasedPremium {
+            resetToNeverPurchasedState()
+        }
+    }
+
+    private func resetToNeverPurchasedState() {
+        hasEverPurchasedPremium = false
+        UserDefaults.standard.set(false, forKey: Self.cachedHasEverPurchasedKey)
+        trialDisplayByPlan = [:]
+    }
+
+    private func refreshTrialOffers() async {
+        guard !isPremium else {
+            trialDisplayByPlan = [:]
+            postStoreStateDidChange()
+            return
+        }
+
+        guard let eligiblePlan = PaywallConfigService.shared.config.trialEligiblePlan else {
+            trialDisplayByPlan = [:]
+            postStoreStateDidChange()
+            return
+        }
+
+        if let display = await resolveTrialDisplay(for: eligiblePlan) {
+            trialDisplayByPlan = [eligiblePlan: display]
+        } else {
+            trialDisplayByPlan = [:]
+        }
+        postStoreStateDidChange()
+    }
+
+    private func resolveTrialDisplay(for plan: PaywallPlan) async -> PaywallTrialDisplay? {
+        guard plan == PaywallConfigService.shared.config.trialEligiblePlan else { return nil }
+        guard !isPremium else { return nil }
+
+        let config = PaywallConfigService.shared.config
+
+        guard let product = product(for: plan),
+              let subscription = product.subscription else {
+            return fallbackTrialDisplay(config: config)
+        }
+
+        guard await subscription.isEligibleForIntroOffer else { return nil }
+
+        if let offer = subscription.introductoryOffer {
+            return PaywallTrialDisplay.from(offer: offer, config: config)
+        }
+
+        return fallbackTrialDisplay(config: config)
+    }
+
+    private func fallbackTrialDisplay(config: PaywallConfig) -> PaywallTrialDisplay? {
+        guard !isPremium else { return nil }
+        guard let fallback = config.trial.fallbackDuration else { return nil }
+        return PaywallTrialDisplay.from(fallback: fallback, config: config)
+    }
+
+    private func applyPremiumStatus(plan: PaywallPlan?) {
+        let hasPremium = plan != nil
+        let kind: PremiumAccessKind
+        switch plan {
+        case .lifetime: kind = .lifetime
+        case .monthly, .yearly: kind = .subscription
+        case nil: kind = .none
+        }
+
+        let previousKind = premiumAccessKind
+        let previousPlan = activePremiumPlan
+        let didChange = hasPremium != isPremium || kind != previousKind || plan != previousPlan
+
+        isPremium = hasPremium
+        activePremiumPlan = plan
+        UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumKey)
+        if let plan {
+            UserDefaults.standard.set(plan.rawValue, forKey: Self.cachedPremiumPlanKey)
+        } else {
+            UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
+        }
+
+        if didChange {
+            NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
+        }
+        postStoreStateDidChange()
+    }
+
+    private func planGrantingPremium(from transaction: StoreKit.Transaction) -> PaywallPlan? {
+        guard transactionGrantsPremium(transaction) else { return nil }
+        return productCatalog.plan(for: transaction.productID)
+    }
+
+    private func transactionGrantsPremium(_ transaction: StoreKit.Transaction) -> Bool {
+        guard productCatalog.allProductIDs.contains(transaction.productID) else { return false }
+        guard transaction.revocationDate == nil else { return false }
+        if let expirationDate = transaction.expirationDate, expirationDate < Date() {
+            return false
+        }
+        return true
+    }
+
+    private func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
+        if current == .lifetime || candidate == .lifetime {
+            return .lifetime
+        }
+        if current == .yearly || candidate == .yearly {
+            return .yearly
+        }
+        if current == .monthly || candidate == .monthly {
+            return .monthly
+        }
+        return candidate
+    }
+
+    private func activeLifetimePlan() async -> PaywallPlan? {
+        let lifetimeProductID = PaywallPlan.lifetime.productID
+        guard let result = await Transaction.latest(for: lifetimeProductID) else {
+            return nil
+        }
+        guard case .verified(let transaction) = result else { return nil }
+        return planGrantingPremium(from: transaction)
+    }
+
+    private func activeSubscriptionPlan() async -> PaywallPlan? {
+        var resolvedPlan: PaywallPlan?
+
+        for await result in Transaction.currentEntitlements {
+            guard case .verified(let transaction) = result else { continue }
+            guard let plan = planGrantingPremium(from: transaction), plan != .lifetime else { continue }
+            resolvedPlan = preferredPlan(resolvedPlan, plan)
+        }
+
+        return resolvedPlan
+    }
+
+    private func activePlanFromSubscriptionStatus() async -> PaywallPlan? {
+        let subscriptionPlans = [PaywallPlan.monthly, .yearly]
+        for plan in subscriptionPlans {
+            let productID = plan.productID
+            let product: Product?
+            if let cached = productsByID[productID] {
+                product = cached
+            } else {
+                product = try? await Product.products(for: [productID]).first
+            }
+
+            guard let product,
+                  let subscription = product.subscription,
+                  let statuses = try? await subscription.status else {
+                continue
+            }
+
+            for status in statuses {
+                switch status.state {
+                case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
+                    return plan
+                default:
+                    continue
+                }
+            }
+        }
+        return nil
+    }
+
+    private func listenForTransactionUpdates() async {
+        for await update in Transaction.updates {
+            guard case .verified(let transaction) = update else { continue }
+
+            if isRevokedAppTransaction(transaction) {
+                revokePremiumAccess()
+                await transaction.finish()
+                await refreshPremiumAccess()
+                continue
+            }
+
+            let trustedPlan = planGrantingPremium(from: transaction)
+            if let trustedPlan {
+                applyPremiumAccess(from: transaction)
+            }
+
+            await transaction.finish()
+
+            if trustedPlan != nil {
+                await reconcilePremiumAccess(trustedPlan: trustedPlan)
+            } else {
+                await refreshPremiumAccess()
+            }
+        }
+    }
+
+    private func isRevokedAppTransaction(_ transaction: StoreKit.Transaction) -> Bool {
+        productCatalog.allProductIDs.contains(transaction.productID) && transaction.revocationDate != nil
+    }
+
+    private func postStoreStateDidChange() {
+        NotificationCenter.default.post(name: .storeStateDidChange, object: nil)
+    }
+}
+
+extension Notification.Name {
+    static let premiumStatusDidChange = Notification.Name("premiumStatusDidChange")
+    static let entitlementsDidResolve = Notification.Name("entitlementsDidResolve")
+    static let storeProductsDidUpdate = Notification.Name("storeProductsDidUpdate")
+    static let storeStateDidChange = Notification.Name("storeStateDidChange")
+}

+ 40 - 2
smart_printer/PaywallConfig.swift

@@ -3,6 +3,7 @@ import StoreKit
 
 struct PaywallConfig: Codable, Sendable {
     struct PlanCopy: Codable, Sendable {
+        let productID: String
         let title: String
         let badge: String?
         let subtitleTemplate: String
@@ -14,6 +15,7 @@ struct PaywallConfig: Codable, Sendable {
         let features: [String]
 
         init(
+            productID: String,
             title: String,
             badge: String? = nil,
             subtitleTemplate: String,
@@ -24,6 +26,7 @@ struct PaywallConfig: Codable, Sendable {
             fallbackBillingDescription: String = "",
             features: [String] = []
         ) {
+            self.productID = productID
             self.title = title
             self.badge = badge
             self.subtitleTemplate = subtitleTemplate
@@ -155,7 +158,7 @@ struct PaywallConfig: Codable, Sendable {
 }
 
 extension PaywallConfig {
-    /// Applies remote marketing copy only. Legal URLs and subscription disclosures stay bundled.
+    /// Applies remote marketing copy only. Product IDs, legal URLs, and disclosures stay bundled.
     func mergingMarketing(from remote: PaywallConfig) -> PaywallConfig {
         PaywallConfig(
             leftPanelTitle: remote.leftPanelTitle,
@@ -164,7 +167,7 @@ extension PaywallConfig {
             features: remote.features,
             trustItems: remote.trustItems,
             lifetimeTrustItem: remote.lifetimeTrustItem,
-            plans: remote.plans,
+            plans: plans.mergingMarketing(from: remote.plans),
             footer: footer,
             urls: urls,
             trial: trial,
@@ -289,6 +292,41 @@ extension PaywallConfig {
         default: nil
         }
     }
+
+    var allProductIDs: Set<String> {
+        Set(PaywallPlan.displayOrder.map(\.productID))
+    }
+
+    func plan(for productID: String) -> PaywallPlan? {
+        PaywallPlan.displayOrder.first { $0.productID == productID }
+    }
+}
+
+extension PaywallConfig.Plans {
+    func mergingMarketing(from remote: PaywallConfig.Plans) -> PaywallConfig.Plans {
+        PaywallConfig.Plans(
+            monthly: monthly.mergingMarketing(from: remote.monthly),
+            yearly: yearly.mergingMarketing(from: remote.yearly),
+            lifetime: lifetime.mergingMarketing(from: remote.lifetime)
+        )
+    }
+}
+
+extension PaywallConfig.PlanCopy {
+    func mergingMarketing(from remote: PaywallConfig.PlanCopy) -> PaywallConfig.PlanCopy {
+        PaywallConfig.PlanCopy(
+            productID: productID,
+            title: remote.title,
+            badge: remote.badge,
+            subtitleTemplate: remote.subtitleTemplate,
+            trialSubtitleTemplate: remote.trialSubtitleTemplate,
+            ctaTemplate: remote.ctaTemplate,
+            fallbackPrice: remote.fallbackPrice,
+            priceSuffix: remote.priceSuffix,
+            fallbackBillingDescription: remote.fallbackBillingDescription,
+            features: remote.features
+        )
+    }
 }
 
 extension Notification.Name {

+ 1 - 5
smart_printer/PaywallModels.swift

@@ -9,11 +9,7 @@ enum PaywallPlan: String, CaseIterable {
     static let displayOrder: [PaywallPlan] = [.monthly, .yearly, .lifetime]
 
     var productID: String {
-        switch self {
-        case .monthly: StoreProductID.monthly
-        case .yearly: StoreProductID.yearly
-        case .lifetime: StoreProductID.lifetime
-        }
+        planCopy(from: PaywallConfig.loadBundled()).productID
     }
 
     func planCopy(from config: PaywallConfig) -> PaywallConfig.PlanCopy {

+ 0 - 835
smart_printer/PaywallView.swift

@@ -1,841 +1,6 @@
 import Cocoa
 import StoreKit
 
-// MARK: - StoreKit
-
-enum StoreProductID {
-    static let monthly = AppStoreConfig.ProductID.monthly
-    static let yearly = AppStoreConfig.ProductID.yearly
-    static let lifetime = AppStoreConfig.ProductID.lifetime
-
-    static let all = AppStoreConfig.ProductID.all
-
-    static func plan(for productID: String) -> PaywallPlan? {
-        switch productID {
-        case monthly: .monthly
-        case yearly: .yearly
-        case lifetime: .lifetime
-        default: nil
-        }
-    }
-}
-
-enum PremiumAccessKind: String {
-    case none
-    case subscription
-    case lifetime
-}
-
-enum StoreError: Error {
-    case productNotFound
-    case failedVerification
-}
-
-enum PurchaseOutcome: Sendable {
-    case purchased
-    case cancelled
-    case pending
-}
-
-@MainActor
-final class StoreManager {
-    static let shared = StoreManager()
-
-    private enum ProductLoadError: Error {
-        case timeout
-    }
-
-    private enum StoreSyncError: Error {
-        case timeout
-    }
-
-    private static let cachedPremiumKey = "StoreManager.cachedIsPremium"
-    private static let cachedPremiumPlanKey = "StoreManager.cachedPremiumPlan"
-    private static let cachedHasEverPurchasedKey = "StoreManager.cachedHasEverPurchased"
-    private static let backgroundRefreshInterval: TimeInterval = 120
-
-    private(set) var productsByID: [String: Product] = [:]
-    private(set) var isPremium = UserDefaults.standard.bool(forKey: cachedPremiumKey)
-    private(set) var activePremiumPlan: PaywallPlan?
-    private(set) var hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: cachedHasEverPurchasedKey)
-    private(set) var trialDisplayByPlan: [PaywallPlan: PaywallTrialDisplay] = [:]
-    private(set) var purchaseError: PurchaseError?
-
-    var isPro: Bool { isPremium }
-    var hasActiveSubscription: Bool { activePremiumPlan == .monthly || activePremiumPlan == .yearly }
-    var hasLifetimeAccess: Bool { activePremiumPlan == .lifetime }
-    var premiumAccessKind: PremiumAccessKind {
-        switch activePremiumPlan {
-        case .lifetime: .lifetime
-        case .monthly, .yearly: .subscription
-        case nil: .none
-        }
-    }
-
-    var products: [Product] {
-        availablePlans.compactMap { productsByID[$0.productID] }
-    }
-
-    var availablePlans: [PaywallPlan] {
-        PaywallPlan.displayOrder.filter { productsByID[$0.productID] != nil }
-    }
-
-    var isResolvingEntitlements: Bool { !hasResolvedPremiumStatus }
-    var hasResolvedPremiumStatus = false
-    var hasAvailablePlans: Bool { !availablePlans.isEmpty }
-    var hasAllProductsLoaded: Bool { hasAvailablePlans }
-
-    private(set) var isLoadingProducts = false
-    private(set) var productLoadError: String?
-    private(set) var purchasingPlan: PaywallPlan?
-    private(set) var isRestoringPurchases = false
-
-    var isPurchasing: Bool { purchasingPlan != nil || isRestoringPurchases }
-
-    private var inFlightProductLoad: Task<Void, Never>?
-    private var transactionListener: Task<Void, Never>?
-    private var initialEntitlementTask: Task<Void, Never>?
-    private var entitlementRefreshTask: Task<Void, Never>?
-    private var hasStarted = false
-    private var lastBackgroundEntitlementRefresh: Date?
-    private var appActiveObserver: NSObjectProtocol?
-
-    private init() {
-        migrateLegacyPremiumCacheIfNeeded()
-        hasEverPurchasedPremium = UserDefaults.standard.bool(forKey: Self.cachedHasEverPurchasedKey)
-
-        if UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) {
-            isPremium = true
-            activePremiumPlan = restoredCachedPremiumPlan()
-        }
-    }
-
-    private func migrateLegacyPremiumCacheIfNeeded() {
-        guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return }
-        guard UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey) == nil,
-              let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") else {
-            return
-        }
-
-        let migratedPlan: String?
-        switch legacyKind {
-        case PremiumAccessKind.lifetime.rawValue:
-            migratedPlan = PaywallPlan.lifetime.rawValue
-        case PremiumAccessKind.subscription.rawValue:
-            migratedPlan = PaywallPlan.yearly.rawValue
-        default:
-            migratedPlan = nil
-        }
-
-        if let migratedPlan {
-            UserDefaults.standard.set(migratedPlan, forKey: Self.cachedPremiumPlanKey)
-        }
-    }
-
-    private func restoredCachedPremiumPlan() -> PaywallPlan? {
-        guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return nil }
-
-        if let raw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
-           let plan = PaywallPlan(rawValue: raw) {
-            return plan
-        }
-
-        if let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") {
-            switch legacyKind {
-            case PremiumAccessKind.lifetime.rawValue:
-                return .lifetime
-            case PremiumAccessKind.subscription.rawValue:
-                return .yearly
-            default:
-                break
-            }
-        }
-
-        return .yearly
-    }
-
-    func start() {
-        guard !hasStarted else { return }
-        hasStarted = true
-
-        observeAppLifecycle()
-        transactionListener = Task { [weak self] in
-            await self?.listenForTransactionUpdates()
-        }
-
-        initialEntitlementTask = Task { [weak self] in
-            guard let self else { return }
-            await self.refreshPremiumAccess()
-            await self.loadProducts(presentLoadingUI: false)
-        }
-    }
-
-    private func observeAppLifecycle() {
-        appActiveObserver = NotificationCenter.default.addObserver(
-            forName: NSApplication.didBecomeActiveNotification,
-            object: nil,
-            queue: .main
-        ) { [weak self] _ in
-            Task { @MainActor in
-                self?.refreshEntitlementsOnAppActive()
-            }
-        }
-    }
-
-    private func refreshEntitlementsOnAppActive() {
-        guard hasStarted else { return }
-
-        if let lastRefresh = lastBackgroundEntitlementRefresh,
-           Date().timeIntervalSince(lastRefresh) < Self.backgroundRefreshInterval {
-            return
-        }
-
-        entitlementRefreshTask?.cancel()
-        entitlementRefreshTask = Task {
-            await refreshPremiumAccess()
-            lastBackgroundEntitlementRefresh = Date()
-        }
-    }
-
-    func ensureEntitlementsResolved() async {
-        await initialEntitlementTask?.value
-    }
-
-    func ensurePaywallStoreDataLoaded(presentLoadingUI: Bool = true) async {
-        await ensureEntitlementsResolved()
-        await loadProducts(presentLoadingUI: presentLoadingUI && !hasAllProductsLoaded)
-        await refreshTrialOffers()
-    }
-
-    func product(for plan: PaywallPlan) -> Product? {
-        productsByID[plan.productID]
-    }
-
-    func trialDisplay(for plan: PaywallPlan) -> PaywallTrialDisplay? {
-        trialDisplayByPlan[plan]
-    }
-
-    func clearPurchaseError() {
-        purchaseError = nil
-    }
-
-    var isEligibleForIntroTrial: Bool {
-        !hasEverPurchasedPremium && !isPremium
-    }
-
-    func loadProducts(presentLoadingUI: Bool? = nil) async {
-        if let existing = inFlightProductLoad {
-            await existing.value
-            return
-        }
-
-        let uiFlag = presentLoadingUI
-        let task = Task { @MainActor in
-            await performLoadProducts(presentLoadingUI: uiFlag)
-        }
-        inFlightProductLoad = task
-        await task.value
-        inFlightProductLoad = nil
-    }
-
-    @discardableResult
-    func purchase(plan: PaywallPlan) async -> Bool {
-        purchaseError = nil
-
-        var product = productsByID[plan.productID]
-        if product == nil {
-            await loadProducts(presentLoadingUI: false)
-            product = productsByID[plan.productID]
-        }
-
-        guard let product else {
-            purchaseError = products.isEmpty ? .subscriptionUnavailable : .planUnavailable
-            return false
-        }
-
-        purchasingPlan = plan
-        postStoreStateDidChange()
-        defer {
-            purchasingPlan = nil
-            postStoreStateDidChange()
-        }
-
-        do {
-            let result = try await product.purchase()
-            switch result {
-            case .success(let verification):
-                guard case .verified(let transaction) = verification else {
-                    purchaseError = .purchaseNotVerified
-                    return false
-                }
-
-                let trustedPlan = planGrantingPremium(from: transaction)
-                if let trustedPlan {
-                    applyPremiumAccess(from: transaction)
-                }
-
-                await transaction.finish()
-                await reconcilePremiumAccess(trustedPlan: trustedPlan)
-                return isPremium
-            case .userCancelled:
-                return false
-            case .pending:
-                purchaseError = .purchasePending
-                return false
-            @unknown default:
-                return false
-            }
-        } catch {
-            purchaseError = .generic
-            return false
-        }
-    }
-
-    @discardableResult
-    func restorePurchases() async -> Bool {
-        purchaseError = nil
-        isRestoringPurchases = true
-        postStoreStateDidChange()
-        defer {
-            isRestoringPurchases = false
-            postStoreStateDidChange()
-        }
-
-        do {
-            try await syncAppStoreWithTimeout(seconds: 30)
-        } catch is StoreSyncError {
-            purchaseError = .restoreSyncTimeout
-            return false
-        } catch {
-            purchaseError = .generic
-            return false
-        }
-
-        await reconcilePremiumAccess(trustedPlan: nil)
-        await syncPurchaseHistory(allowReset: true)
-        if !isPremium {
-            purchaseError = .noActivePurchases
-        }
-        return isPremium
-    }
-
-    func showAlert(title: String, message: String, on window: NSWindow?) {
-        let alert = NSAlert()
-        alert.messageText = title
-        alert.informativeText = message
-        alert.alertStyle = .informational
-        alert.addButton(withTitle: PaywallConfigService.shared.config.messages.alertOK)
-        if let window {
-            alert.beginSheetModal(for: window)
-        } else {
-            alert.runModal()
-        }
-    }
-
-    func showManageSubscriptions() {
-        let urlString = PaywallConfigService.shared.config.urls.manageSubscriptions
-        guard let url = URL(string: urlString) else { return }
-        NSWorkspace.shared.open(url)
-    }
-
-    func showPurchaseError(_ error: PurchaseError, on window: NSWindow?) {
-        let config = PaywallConfigService.shared.config
-        showAlert(
-            title: config.messages.purchaseFailedTitle,
-            message: config.message(for: error),
-            on: window
-        )
-    }
-
-    private func performLoadProducts(presentLoadingUI: Bool?) async {
-        let shouldReload = presentLoadingUI == true || productsByID.isEmpty
-        let shouldShowLoading = presentLoadingUI ?? productsByID.isEmpty
-
-        if !shouldReload, presentLoadingUI != true {
-            purchaseError = nil
-            await refreshPremiumAccess()
-            await refreshTrialOffers()
-            postStoreStateDidChange()
-            return
-        }
-
-        if shouldShowLoading {
-            isLoadingProducts = true
-            postStoreStateDidChange()
-        }
-        defer {
-            if shouldShowLoading {
-                isLoadingProducts = false
-                postStoreStateDidChange()
-            }
-        }
-
-        purchaseError = nil
-        productLoadError = nil
-        do {
-            let loaded = try await loadProductsWithTimeout(seconds: 12)
-            guard !loaded.isEmpty else {
-                productLoadError = PaywallConfigService.shared.config.messages.noPlansAvailable
-                productsByID = [:]
-                await refreshTrialOffers()
-                return
-            }
-            var map: [String: Product] = [:]
-            for product in loaded {
-                map[product.id] = product
-            }
-            productsByID = map
-            productLoadError = nil
-            NotificationCenter.default.post(name: .storeProductsDidUpdate, object: nil)
-            await refreshPremiumAccess()
-            await refreshTrialOffers()
-        } catch is ProductLoadError {
-            productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
-            purchaseError = .productsLoadTimeout
-            await refreshTrialOffers()
-        } catch {
-            productLoadError = PaywallConfigService.shared.config.messages.plansLoadFailed
-            purchaseError = .generic
-            await refreshTrialOffers()
-        }
-    }
-
-    private func loadProductsWithTimeout(seconds: TimeInterval) async throws -> [Product] {
-        try await withThrowingTaskGroup(of: [Product].self) { group in
-            group.addTask {
-                try await Product.products(for: StoreProductID.all)
-            }
-            group.addTask {
-                let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
-                try await Task.sleep(nanoseconds: timeoutNanoseconds)
-                throw ProductLoadError.timeout
-            }
-
-            guard let first = try await group.next() else {
-                throw ProductLoadError.timeout
-            }
-            group.cancelAll()
-            return first
-        }
-    }
-
-    private func refreshPremiumAccess(trustedPlan: PaywallPlan? = nil) async {
-        var resolvedPlan: PaywallPlan?
-
-        if let lifetimePlan = await activeLifetimePlan() {
-            resolvedPlan = lifetimePlan
-        }
-
-        if resolvedPlan == nil {
-            for await result in Transaction.currentEntitlements {
-                guard case .verified(let transaction) = result else { continue }
-                guard let plan = planGrantingPremium(from: transaction) else { continue }
-                resolvedPlan = preferredPlan(resolvedPlan, plan)
-            }
-        }
-
-        if resolvedPlan == nil || resolvedPlan != .lifetime {
-            if let subscriptionPlan = await activeSubscriptionPlan() {
-                resolvedPlan = preferredPlan(resolvedPlan, subscriptionPlan)
-            }
-        }
-
-        if resolvedPlan == nil {
-            if let statusPlan = await activePlanFromSubscriptionStatus() {
-                resolvedPlan = statusPlan
-            }
-        }
-
-        let finalPlan = mergeTrustedPlan(resolvedPlan, trustedPlan)
-
-        applyPremiumStatus(plan: finalPlan)
-        hasResolvedPremiumStatus = true
-        NotificationCenter.default.post(name: .entitlementsDidResolve, object: nil)
-        await syncPurchaseHistory(allowReset: false)
-        await refreshTrialOffers()
-    }
-
-    private func reconcilePremiumAccess(trustedPlan: PaywallPlan?) async {
-        await refreshPremiumAccess(trustedPlan: trustedPlan)
-
-        guard let trustedPlan, !isPremium else { return }
-
-        do {
-            try await syncAppStoreWithTimeout(seconds: 15)
-        } catch {
-            applyTrustedPlan(trustedPlan)
-            return
-        }
-
-        await refreshPremiumAccess(trustedPlan: trustedPlan)
-        if !isPremium {
-            applyTrustedPlan(trustedPlan)
-        }
-    }
-
-    private func mergeTrustedPlan(_ resolvedPlan: PaywallPlan?, _ trustedPlan: PaywallPlan?) -> PaywallPlan? {
-        switch (resolvedPlan, trustedPlan) {
-        case (nil, nil):
-            nil
-        case (let resolved?, nil):
-            resolved
-        case (nil, let trusted?):
-            trusted
-        case (let resolved?, let trusted?):
-            preferredPlan(resolved, trusted)
-        }
-    }
-
-    private func applyTrustedPlan(_ plan: PaywallPlan) {
-        activePremiumPlan = preferredPlan(activePremiumPlan, plan)
-        applyPremiumStatus(plan: activePremiumPlan)
-        hasResolvedPremiumStatus = true
-        markAsHavingPurchasedPremium()
-    }
-
-    private func revokePremiumAccess() {
-        applyPremiumStatus(plan: nil)
-        hasResolvedPremiumStatus = true
-        trialDisplayByPlan = [:]
-    }
-
-    private func syncAppStoreWithTimeout(seconds: TimeInterval) async throws {
-        try await withThrowingTaskGroup(of: Void.self) { group in
-            group.addTask {
-                try await AppStore.sync()
-            }
-            group.addTask {
-                let timeoutNanoseconds = UInt64(seconds * 1_000_000_000)
-                try await Task.sleep(nanoseconds: timeoutNanoseconds)
-                throw StoreSyncError.timeout
-            }
-
-            try await group.next()
-            group.cancelAll()
-        }
-    }
-
-    private func applyPremiumAccess(from transaction: StoreKit.Transaction) {
-        guard let plan = planGrantingPremium(from: transaction) else { return }
-        activePremiumPlan = preferredPlan(activePremiumPlan, plan)
-        applyPremiumStatus(plan: activePremiumPlan)
-        hasResolvedPremiumStatus = true
-        markAsHavingPurchasedPremium()
-    }
-
-    private func markAsHavingPurchasedPremium() {
-        guard !hasEverPurchasedPremium else { return }
-        hasEverPurchasedPremium = true
-        trialDisplayByPlan = [:]
-        UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
-    }
-
-    private func syncPurchaseHistory(allowReset: Bool) async {
-        var hasRecordedPurchase = false
-
-        for await result in Transaction.all {
-            guard case .verified(let transaction) = result else { continue }
-            guard StoreProductID.all.contains(transaction.productID) else { continue }
-            hasRecordedPurchase = true
-            break
-        }
-
-        if hasRecordedPurchase {
-            if !hasEverPurchasedPremium {
-                hasEverPurchasedPremium = true
-                UserDefaults.standard.set(true, forKey: Self.cachedHasEverPurchasedKey)
-            }
-        } else if allowReset, hasEverPurchasedPremium {
-            resetToNeverPurchasedState()
-        }
-    }
-
-    private func resetToNeverPurchasedState() {
-        hasEverPurchasedPremium = false
-        UserDefaults.standard.set(false, forKey: Self.cachedHasEverPurchasedKey)
-        trialDisplayByPlan = [:]
-    }
-
-    private func refreshTrialOffers() async {
-        guard !isPremium else {
-            trialDisplayByPlan = [:]
-            postStoreStateDidChange()
-            return
-        }
-
-        guard let eligiblePlan = PaywallConfigService.shared.config.trialEligiblePlan else {
-            trialDisplayByPlan = [:]
-            postStoreStateDidChange()
-            return
-        }
-
-        if let display = await resolveTrialDisplay(for: eligiblePlan) {
-            trialDisplayByPlan = [eligiblePlan: display]
-        } else {
-            trialDisplayByPlan = [:]
-        }
-        postStoreStateDidChange()
-    }
-
-    private func resolveTrialDisplay(for plan: PaywallPlan) async -> PaywallTrialDisplay? {
-        guard plan == PaywallConfigService.shared.config.trialEligiblePlan else { return nil }
-        guard !isPremium else { return nil }
-
-        let config = PaywallConfigService.shared.config
-
-        guard let product = product(for: plan),
-              let subscription = product.subscription else {
-            return fallbackTrialDisplay(config: config)
-        }
-
-        guard await subscription.isEligibleForIntroOffer else { return nil }
-
-        if let offer = subscription.introductoryOffer {
-            return PaywallTrialDisplay.from(offer: offer, config: config)
-        }
-
-        return fallbackTrialDisplay(config: config)
-    }
-
-    private func fallbackTrialDisplay(config: PaywallConfig) -> PaywallTrialDisplay? {
-        guard !isPremium else { return nil }
-        guard let fallback = config.trial.fallbackDuration else { return nil }
-        return PaywallTrialDisplay.from(fallback: fallback, config: config)
-    }
-
-    private func applyPremiumStatus(plan: PaywallPlan?) {
-        let hasPremium = plan != nil
-        let kind: PremiumAccessKind
-        switch plan {
-        case .lifetime: kind = .lifetime
-        case .monthly, .yearly: kind = .subscription
-        case nil: kind = .none
-        }
-
-        let previousKind = premiumAccessKind
-        let previousPlan = activePremiumPlan
-        let didChange = hasPremium != isPremium || kind != previousKind || plan != previousPlan
-
-        isPremium = hasPremium
-        activePremiumPlan = plan
-        UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumKey)
-        if let plan {
-            UserDefaults.standard.set(plan.rawValue, forKey: Self.cachedPremiumPlanKey)
-        } else {
-            UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
-        }
-
-        if didChange {
-            NotificationCenter.default.post(name: .premiumStatusDidChange, object: nil)
-        }
-        postStoreStateDidChange()
-    }
-
-    private func planGrantingPremium(from transaction: StoreKit.Transaction) -> PaywallPlan? {
-        guard transactionGrantsPremium(transaction) else { return nil }
-        return StoreProductID.plan(for: transaction.productID)
-    }
-
-    private func transactionGrantsPremium(_ transaction: StoreKit.Transaction) -> Bool {
-        guard StoreProductID.all.contains(transaction.productID) else { return false }
-        guard transaction.revocationDate == nil else { return false }
-        if let expirationDate = transaction.expirationDate, expirationDate < Date() {
-            return false
-        }
-        return true
-    }
-
-    private func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
-        if current == .lifetime || candidate == .lifetime {
-            return .lifetime
-        }
-        if current == .yearly || candidate == .yearly {
-            return .yearly
-        }
-        if current == .monthly || candidate == .monthly {
-            return .monthly
-        }
-        return candidate
-    }
-
-    private func activeLifetimePlan() async -> PaywallPlan? {
-        guard let result = await Transaction.latest(for: StoreProductID.lifetime) else {
-            return nil
-        }
-        guard case .verified(let transaction) = result else { return nil }
-        return planGrantingPremium(from: transaction)
-    }
-
-    private func activeSubscriptionPlan() async -> PaywallPlan? {
-        var resolvedPlan: PaywallPlan?
-
-        for await result in Transaction.currentEntitlements {
-            guard case .verified(let transaction) = result else { continue }
-            guard let plan = planGrantingPremium(from: transaction), plan != .lifetime else { continue }
-            resolvedPlan = preferredPlan(resolvedPlan, plan)
-        }
-
-        return resolvedPlan
-    }
-
-    private func activePlanFromSubscriptionStatus() async -> PaywallPlan? {
-        let productIDs = [StoreProductID.monthly, StoreProductID.yearly]
-        for productID in productIDs {
-            let product: Product?
-            if let cached = productsByID[productID] {
-                product = cached
-            } else {
-                product = try? await Product.products(for: [productID]).first
-            }
-
-            guard let product,
-                  let subscription = product.subscription,
-                  let statuses = try? await subscription.status else {
-                continue
-            }
-
-            for status in statuses {
-                switch status.state {
-                case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
-                    return StoreProductID.plan(for: productID)
-                default:
-                    continue
-                }
-            }
-        }
-        return nil
-    }
-
-    private func listenForTransactionUpdates() async {
-        for await update in Transaction.updates {
-            guard case .verified(let transaction) = update else { continue }
-
-            if isRevokedAppTransaction(transaction) {
-                revokePremiumAccess()
-                await transaction.finish()
-                await refreshPremiumAccess()
-                continue
-            }
-
-            let trustedPlan = planGrantingPremium(from: transaction)
-            if let trustedPlan {
-                applyPremiumAccess(from: transaction)
-            }
-
-            await transaction.finish()
-
-            if trustedPlan != nil {
-                await reconcilePremiumAccess(trustedPlan: trustedPlan)
-            } else {
-                await refreshPremiumAccess()
-            }
-        }
-    }
-
-    private func isRevokedAppTransaction(_ transaction: StoreKit.Transaction) -> Bool {
-        StoreProductID.all.contains(transaction.productID) && transaction.revocationDate != nil
-    }
-
-    private func postStoreStateDidChange() {
-        NotificationCenter.default.post(name: .storeStateDidChange, object: nil)
-    }
-}
-
-extension Notification.Name {
-    static let premiumStatusDidChange = Notification.Name("premiumStatusDidChange")
-    static let entitlementsDidResolve = Notification.Name("entitlementsDidResolve")
-    static let storeProductsDidUpdate = Notification.Name("storeProductsDidUpdate")
-    static let storeStateDidChange = Notification.Name("storeStateDidChange")
-}
-
-// MARK: - Premium Access
-
-enum PremiumAccess {
-    @MainActor
-    static func canAccess(_ feature: AppFeature) -> Bool {
-        let store = StoreManager.shared
-        return FreeTierManager.canAccess(feature, isPremium: store.isPremium)
-    }
-
-    @MainActor
-    @discardableResult
-    static func require(
-        feature: AppFeature,
-        from window: NSWindow?,
-        returnToHomeOnDismiss: Bool = false
-    ) -> Bool {
-        guard canAccess(feature) else {
-            presentPaywallOrLimitAlert(for: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
-            return false
-        }
-        return true
-    }
-
-    @MainActor
-    @discardableResult
-    static func requireForAction(
-        feature: AppFeature,
-        from window: NSWindow?,
-        returnToHomeOnDismiss: Bool = false,
-        recordFreeUse: Bool = true
-    ) -> Bool {
-        guard require(feature: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss) else {
-            return false
-        }
-        if recordFreeUse {
-            recordFreeUseIfNeeded()
-        }
-        return true
-    }
-
-    @MainActor
-    static func recordFreeUseIfNeeded() {
-        guard !StoreManager.shared.isPremium else { return }
-        FreeTierManager.recordUseIfNeeded(isPremium: false)
-    }
-
-    @MainActor
-    @discardableResult
-    static func require(from window: NSWindow?, returnToHomeOnDismiss: Bool = false) -> Bool {
-        require(feature: .documentImport, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
-    }
-
-    @MainActor
-    private static func presentPaywallOrLimitAlert(
-        for feature: AppFeature,
-        from window: NSWindow?,
-        returnToHomeOnDismiss: Bool
-    ) {
-        if StoreManager.shared.isResolvingEntitlements {
-            Task { @MainActor in
-                await StoreManager.shared.ensureEntitlementsResolved()
-                guard !canAccess(feature) else { return }
-                presentPaywallOrLimitAlert(for: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
-            }
-            return
-        }
-
-        guard let viewController = mainViewController(from: window) else { return }
-        viewController.presentPaywall(returnToHomeOnDismiss: returnToHomeOnDismiss)
-    }
-
-    @MainActor
-    static func mainViewController(from window: NSWindow?) -> ViewController? {
-        if let viewController = window?.contentViewController as? ViewController {
-            return viewController
-        }
-        if let viewController = NSApp.keyWindow?.contentViewController as? ViewController {
-            return viewController
-        }
-        for appWindow in NSApp.windows where appWindow.canBecomeMain {
-            if let viewController = appWindow.contentViewController as? ViewController {
-                return viewController
-            }
-        }
-        return nil
-    }
-}
-
 // MARK: - Left Panel
 
 private final class PaywallLeftPanelView: NSView, AppearanceRefreshable {

+ 87 - 0
smart_printer/Utilities/PremiumAccess.swift

@@ -0,0 +1,87 @@
+import Cocoa
+
+enum PremiumAccess {
+    @MainActor
+    static func canAccess(_ feature: AppFeature) -> Bool {
+        let store = StoreManager.shared
+        return FreeTierManager.canAccess(feature, isPremium: store.isPremium)
+    }
+
+    @MainActor
+    @discardableResult
+    static func require(
+        feature: AppFeature,
+        from window: NSWindow?,
+        returnToHomeOnDismiss: Bool = false
+    ) -> Bool {
+        guard canAccess(feature) else {
+            presentPaywallOrLimitAlert(for: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
+            return false
+        }
+        return true
+    }
+
+    @MainActor
+    @discardableResult
+    static func requireForAction(
+        feature: AppFeature,
+        from window: NSWindow?,
+        returnToHomeOnDismiss: Bool = false,
+        recordFreeUse: Bool = true
+    ) -> Bool {
+        guard require(feature: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss) else {
+            return false
+        }
+        if recordFreeUse {
+            recordFreeUseIfNeeded()
+        }
+        return true
+    }
+
+    @MainActor
+    static func recordFreeUseIfNeeded() {
+        guard !StoreManager.shared.isPremium else { return }
+        FreeTierManager.recordUseIfNeeded(isPremium: false)
+    }
+
+    @MainActor
+    @discardableResult
+    static func require(from window: NSWindow?, returnToHomeOnDismiss: Bool = false) -> Bool {
+        require(feature: .documentImport, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
+    }
+
+    @MainActor
+    private static func presentPaywallOrLimitAlert(
+        for feature: AppFeature,
+        from window: NSWindow?,
+        returnToHomeOnDismiss: Bool
+    ) {
+        if StoreManager.shared.isResolvingEntitlements {
+            Task { @MainActor in
+                await StoreManager.shared.ensureEntitlementsResolved()
+                guard !canAccess(feature) else { return }
+                presentPaywallOrLimitAlert(for: feature, from: window, returnToHomeOnDismiss: returnToHomeOnDismiss)
+            }
+            return
+        }
+
+        guard let viewController = mainViewController(from: window) else { return }
+        viewController.presentPaywall(returnToHomeOnDismiss: returnToHomeOnDismiss)
+    }
+
+    @MainActor
+    static func mainViewController(from window: NSWindow?) -> ViewController? {
+        if let viewController = window?.contentViewController as? ViewController {
+            return viewController
+        }
+        if let viewController = NSApp.keyWindow?.contentViewController as? ViewController {
+            return viewController
+        }
+        for appWindow in NSApp.windows where appWindow.canBecomeMain {
+            if let viewController = appWindow.contentViewController as? ViewController {
+                return viewController
+            }
+        }
+        return nil
+    }
+}

+ 3 - 0
smart_printer/paywall.json

@@ -39,6 +39,7 @@
   },
   "plans": {
     "monthly": {
+      "productID": "com.printer_app_all_printers.premium.monthly",
       "title": "Monthly",
       "subtitleTemplate": "{price} / month, cancel anytime",
       "ctaTemplate": "Subscribe for {price} / Month",
@@ -52,6 +53,7 @@
       ]
     },
     "yearly": {
+      "productID": "com.printer_app_all_printers.premium.yearly",
       "title": "Yearly",
       "subtitleTemplate": "{price} / year, cancel anytime",
       "trialSubtitleTemplate": "{trial} free, then {price} / year",
@@ -66,6 +68,7 @@
       ]
     },
     "lifetime": {
+      "productID": "com.printer_app_all_printers.premium.lifetime",
       "title": "Lifetime",
       "subtitleTemplate": "{price} once, lifetime access",
       "ctaTemplate": "Buy Lifetime Access for {price}",