Browse Source

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 tuần trước cách đây
mục cha
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}",